openmd-cli 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.
- openmd/__init__.py +3 -0
- openmd/__main__.py +5 -0
- openmd/adapters/__init__.py +1 -0
- openmd/adapters/base.py +95 -0
- openmd/adapters/bilibili/__init__.py +11 -0
- openmd/adapters/bilibili/adapter.py +104 -0
- openmd/adapters/bilibili/discovery.py +33 -0
- openmd/adapters/douyin/__init__.py +11 -0
- openmd/adapters/douyin/adapter.py +117 -0
- openmd/adapters/douyin/discovery.py +36 -0
- openmd/adapters/douyin/sidecar.py +77 -0
- openmd/adapters/generic_media/__init__.py +14 -0
- openmd/adapters/high_privacy.py +77 -0
- openmd/adapters/local_audio/__init__.py +14 -0
- openmd/adapters/local_audio/adapter.py +169 -0
- openmd/adapters/local_audio/asr.py +58 -0
- openmd/adapters/local_audio/backends/__init__.py +1 -0
- openmd/adapters/local_audio/backends/auto.py +177 -0
- openmd/adapters/local_audio/backends/faster_whisper.py +285 -0
- openmd/adapters/local_audio/backends/funasr_sense_voice.py +14 -0
- openmd/adapters/local_audio/backends/qwen3_asr.py +254 -0
- openmd/adapters/opencli.py +770 -0
- openmd/adapters/pdf/__init__.py +11 -0
- openmd/adapters/pdf/adapter.py +153 -0
- openmd/adapters/pdf/backends/__init__.py +1 -0
- openmd/adapters/pdf/backends/markitdown.py +165 -0
- openmd/adapters/pdf/parser.py +56 -0
- openmd/adapters/qq_group/__init__.py +11 -0
- openmd/adapters/qq_group/adapter.py +289 -0
- openmd/adapters/qq_group/parsers/__init__.py +1 -0
- openmd/adapters/qq_group/parsers/html.py +60 -0
- openmd/adapters/qq_group/parsers/json.py +71 -0
- openmd/adapters/registry.py +149 -0
- openmd/adapters/screenshot/__init__.py +11 -0
- openmd/adapters/screenshot/adapter.py +154 -0
- openmd/adapters/screenshot/backends/__init__.py +1 -0
- openmd/adapters/screenshot/backends/paddle.py +15 -0
- openmd/adapters/screenshot/backends/rapidocr.py +176 -0
- openmd/adapters/screenshot/backends/tesseract.py +15 -0
- openmd/adapters/screenshot/ocr.py +84 -0
- openmd/adapters/wechat_group/__init__.py +11 -0
- openmd/adapters/wechat_group/adapter.py +160 -0
- openmd/adapters/wechat_group/wx_cli.py +268 -0
- openmd/adapters/wechat_moments/__init__.py +11 -0
- openmd/adapters/wechat_moments/adapter.py +167 -0
- openmd/adapters/wechat_moments/wx_cli.py +265 -0
- openmd/adapters/weixin/__init__.py +11 -0
- openmd/adapters/weixin/adapter.py +148 -0
- openmd/adapters/weixin/discovery.py +510 -0
- openmd/adapters/weixin/report.py +278 -0
- openmd/adapters/xiaohongshu.py +158 -0
- openmd/backends/__init__.py +1 -0
- openmd/backends/base.py +204 -0
- openmd/backends/bilibili.py +1480 -0
- openmd/backends/bilibili_wbi.py +277 -0
- openmd/backends/discourse.py +426 -0
- openmd/backends/douyin.py +868 -0
- openmd/backends/douyin_detail.py +124 -0
- openmd/backends/douyin_providers.py +602 -0
- openmd/backends/executor.py +546 -0
- openmd/backends/feishu.py +861 -0
- openmd/backends/generic_media.py +77 -0
- openmd/backends/interactive.py +60 -0
- openmd/backends/ocr.py +41 -0
- openmd/backends/opencli.py +82 -0
- openmd/backends/pdf.py +122 -0
- openmd/backends/registry.py +234 -0
- openmd/backends/screenshot.py +79 -0
- openmd/backends/video_metadata.py +167 -0
- openmd/backends/web_article.py +363 -0
- openmd/backends/wechat_group.py +35 -0
- openmd/backends/wechat_moments.py +35 -0
- openmd/backends/weixin.py +767 -0
- openmd/backends/x.py +455 -0
- openmd/backends/xiaoe.py +940 -0
- openmd/backends/xiaohongshu.py +911 -0
- openmd/backends/xiaohongshu_providers.py +876 -0
- openmd/backends/xiaoyuzhou.py +1017 -0
- openmd/backends/youtube.py +280 -0
- openmd/backends/youtube_discovery.py +1245 -0
- openmd/backends/youtube_transcript.py +213 -0
- openmd/browser_extension/background.js +231 -0
- openmd/browser_extension/icons/icon-128.png +0 -0
- openmd/browser_extension/icons/icon-16.png +0 -0
- openmd/browser_extension/icons/icon-32.png +0 -0
- openmd/browser_extension/icons/icon-48.png +0 -0
- openmd/browser_extension/icons/icon-512.png +0 -0
- openmd/browser_extension/idle.html +6 -0
- openmd/browser_extension/manifest.json +43 -0
- openmd/browser_extension/options.html +42 -0
- openmd/browser_extension/options.js +117 -0
- openmd/browser_extension/popup.css +71 -0
- openmd/browser_extension/popup.html +17 -0
- openmd/browser_extension/popup.js +59 -0
- openmd/browser_extension/vendor/opencli-bridge/LICENSE +190 -0
- openmd/browser_extension/vendor/opencli-bridge/VENDOR.md +31 -0
- openmd/browser_extension/vendor/opencli-bridge/background.js +2742 -0
- openmd/browser_host.py +503 -0
- openmd/browser_setup.py +106 -0
- openmd/cli.py +2012 -0
- openmd/console.py +32 -0
- openmd/core/__init__.py +1 -0
- openmd/core/capabilities.py +162 -0
- openmd/core/content_validation.py +323 -0
- openmd/core/coverage.py +163 -0
- openmd/core/detector.py +247 -0
- openmd/core/doctor.py +104 -0
- openmd/core/execution_policy.py +164 -0
- openmd/core/filename.py +64 -0
- openmd/core/input_validation.py +91 -0
- openmd/core/local_config.py +96 -0
- openmd/core/manifest.py +156 -0
- openmd/core/models.py +168 -0
- openmd/core/opencli_output.py +34 -0
- openmd/core/operation_contract.py +60 -0
- openmd/core/output.py +194 -0
- openmd/core/package_contract.py +335 -0
- openmd/core/package_validator.py +489 -0
- openmd/core/package_writer.py +666 -0
- openmd/core/planner.py +345 -0
- openmd/core/preferences.py +61 -0
- openmd/core/readable.py +129 -0
- openmd/core/receipt.py +285 -0
- openmd/core/renderer.py +185 -0
- openmd/core/runlog.py +208 -0
- openmd/core/secrets.py +313 -0
- openmd/doctor_view.py +68 -0
- openmd/interactive.py +453 -0
- openmd/ocr/__init__.py +1 -0
- openmd/ocr/breakers.py +84 -0
- openmd/ocr/config.py +54 -0
- openmd/ocr/documents.py +522 -0
- openmd/ocr/endpoint.py +72 -0
- openmd/ocr/layout.py +92 -0
- openmd/ocr/layout_filter.py +118 -0
- openmd/ocr/layout_worker.py +89 -0
- openmd/ocr/native_math.py +220 -0
- openmd/ocr/office.py +305 -0
- openmd/ocr/ollama.py +200 -0
- openmd/ocr/otsl.py +51 -0
- openmd/ocr/ovis-prompt.txt +1 -0
- openmd/ocr/pipeline.py +384 -0
- openmd/ocr/settings.json +25 -0
- openmd/ocr/tiling.py +631 -0
- openmd/post_video.py +154 -0
- openmd/probe.py +397 -0
- openmd/routing/__init__.py +1 -0
- openmd/routing/cards/builtin.json +620 -0
- openmd/routing/cards/defuddle.json +70 -0
- openmd/routing/cards/x-tweet-fetcher.json +71 -0
- openmd/routing/cards/xyz-dl.json +60 -0
- openmd/routing/cards/yutto.json +68 -0
- openmd/routing/entry_shim.py +71 -0
- openmd/routing/lifecycle.py +459 -0
- openmd/routing/loader.py +138 -0
- openmd/routing/providers.py +158 -0
- openmd/routing/readers.py +108 -0
- openmd/routing/routes.json +504 -0
- openmd/routing/runner.py +192 -0
- openmd/routing/view.py +40 -0
- openmd/runtime/__init__.py +1 -0
- openmd/runtime/_windows_job.py +234 -0
- openmd/runtime/asr.py +217 -0
- openmd/runtime/asr_worker.py +119 -0
- openmd/runtime/gpu_lock.py +81 -0
- openmd/runtime/gpu_quarantine.py +85 -0
- openmd/runtime/interpreters.py +407 -0
- openmd/runtime/managed_tools.py +204 -0
- openmd/runtime/models.py +202 -0
- openmd/runtime/services.py +175 -0
- openmd/runtime/tempdirs.py +227 -0
- openmd_cli-0.1.0.dist-info/METADATA +168 -0
- openmd_cli-0.1.0.dist-info/RECORD +177 -0
- openmd_cli-0.1.0.dist-info/WHEEL +5 -0
- openmd_cli-0.1.0.dist-info/entry_points.txt +2 -0
- openmd_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- openmd_cli-0.1.0.dist-info/top_level.txt +1 -0
openmd/ocr/pipeline.py
ADDED
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
"""Serial page/tile OCR, monitored Ovis3 → layout MiniCPM fallback."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import math
|
|
8
|
+
import os
|
|
9
|
+
import re
|
|
10
|
+
import subprocess
|
|
11
|
+
import sys
|
|
12
|
+
import tempfile
|
|
13
|
+
import threading
|
|
14
|
+
import time
|
|
15
|
+
import uuid
|
|
16
|
+
from importlib.resources import files
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from openmd.adapters.local_audio.adapter import local_path_from_input
|
|
20
|
+
from openmd.adapters.screenshot.ocr import OCRBackend, classify_ocr_profile
|
|
21
|
+
from openmd.core.local_config import checkout_root
|
|
22
|
+
from openmd.core.models import TranscriptResult
|
|
23
|
+
from openmd.runtime.gpu_lock import gpu_lock, gpu_lock_path
|
|
24
|
+
from openmd.runtime.gpu_quarantine import GPUQuarantined, clear_quarantine, mark_quarantine
|
|
25
|
+
from .breakers import breaker_reason, estimate_expected_chars
|
|
26
|
+
from .config import settings
|
|
27
|
+
from .layout import LayoutUnavailable, detect_regions
|
|
28
|
+
from .ollama import OllamaError, api_json, model_identity, running_models, stream_recognize, unload_model
|
|
29
|
+
from .otsl import table_html
|
|
30
|
+
from .tiling import merge_pair, norm_view, repeated_ngrams, split_image
|
|
31
|
+
|
|
32
|
+
from openmd.runtime.tempdirs import owned_temporary_directory
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def han_ratio(text: str) -> float:
|
|
36
|
+
chars = [c for c in text if c.isalnum()]
|
|
37
|
+
return round(sum("\u3400" <= c <= "\u9fff" for c in chars) / len(chars), 5) if chars else 0.0
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def ocr_notes(pages: list[dict]) -> list[str]:
|
|
41
|
+
identities = sorted({f"{a['engine']} model={a.get('model')} digest={a.get('digest')}" for page in pages for tile in page.get("tiles", [page]) for a in tile.get("attempts", [])})
|
|
42
|
+
used = any(page.get("fallback_used") for page in pages)
|
|
43
|
+
reasons = sorted({str(page.get("reason")) for page in pages if page.get("reason")})
|
|
44
|
+
return [*identities, f"fallback_used={str(used).lower()}; reason={'; '.join(reasons) or 'none'}", "识别模型可能自动纠正原文错字", "fallback_used=false 不代表无幻觉", "Raw OCR Markdown only; no summary, paraphrase, translation or polishing was requested.", "Image/chart asset export is not implemented; bbox image tags are source references, not exported files."]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class GpuSampler:
|
|
48
|
+
"""Optional whole-device memory observation; not model allocation accounting."""
|
|
49
|
+
def __enter__(self):
|
|
50
|
+
self.values = []
|
|
51
|
+
self.stop = threading.Event()
|
|
52
|
+
|
|
53
|
+
def sample():
|
|
54
|
+
while not self.stop.is_set():
|
|
55
|
+
try:
|
|
56
|
+
value = subprocess.run(["nvidia-smi", "--query-gpu=memory.used", "--format=csv,noheader,nounits"], capture_output=True, text=True, timeout=3, creationflags=subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0)
|
|
57
|
+
self.values.append(float(value.stdout.strip().splitlines()[0]))
|
|
58
|
+
except (OSError, ValueError, IndexError, subprocess.SubprocessError):
|
|
59
|
+
return
|
|
60
|
+
self.stop.wait(0.35)
|
|
61
|
+
|
|
62
|
+
self.thread = threading.Thread(target=sample, name="openmd-gpu-sampler", daemon=True)
|
|
63
|
+
self.thread.start()
|
|
64
|
+
return self
|
|
65
|
+
|
|
66
|
+
def __exit__(self, *_):
|
|
67
|
+
self.stop.set()
|
|
68
|
+
self.thread.join(timeout=4)
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def peak(self):
|
|
72
|
+
return max(self.values) if self.values else None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def retain_breaker_output(text: str, attempt: dict) -> str | None:
|
|
76
|
+
if not text:
|
|
77
|
+
return None
|
|
78
|
+
root = checkout_root()
|
|
79
|
+
directory = root / "out/ocr-router/breaker-evidence" if root else gpu_lock_path().parent / "ocr-evidence"
|
|
80
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
81
|
+
target = directory / (uuid.uuid4().hex + ".md")
|
|
82
|
+
target.write_text(text, encoding="utf-8")
|
|
83
|
+
return str(target)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def retain_seam_outputs(a: str, b: str, arbitrator: str | None, seam: dict, *, merged_a: str | None = None) -> dict:
|
|
87
|
+
"""Private OCR readings belong in ignored evidence, never JSON receipts."""
|
|
88
|
+
root = checkout_root()
|
|
89
|
+
directory = root / "out/ocr-router/seam-evidence" if root else gpu_lock_path().parent / "ocr-evidence"
|
|
90
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
91
|
+
target = directory / (uuid.uuid4().hex + ".json")
|
|
92
|
+
payload = json.dumps({"a": merged_a if merged_a is not None else a, "previous_tile": a, "b": b, "arbitrator": arbitrator, "alignment": seam, "mapping_basis": "a=accumulated_merge_before_seam,b=current_raw_tile"}, ensure_ascii=False, indent=2)
|
|
93
|
+
target.write_text(payload, encoding="utf-8")
|
|
94
|
+
return {"path": str(target), "sha256": hashlib.sha256(payload.encode()).hexdigest(),
|
|
95
|
+
"a_chars": len(a), "b_chars": len(b), "arbitrator_chars": len(arbitrator) if arbitrator is not None else None}
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def seam_geometry(a: str, b: str, previous: tuple, current: tuple) -> dict:
|
|
99
|
+
"""Project text lines monotonically onto the actual shared image band.
|
|
100
|
+
|
|
101
|
+
OCR has no word coordinates, so this projection limits candidate search,
|
|
102
|
+
never proves a deletion. The independent arbitration crop uses the exact
|
|
103
|
+
pixel intersection, not this text-position estimate.
|
|
104
|
+
"""
|
|
105
|
+
y0, y1 = max(previous[1], current[1]), min(previous[3], current[3])
|
|
106
|
+
height = max(0, y1 - y0)
|
|
107
|
+
def window(text, fraction, tail):
|
|
108
|
+
lines = text.splitlines(keepends=True)
|
|
109
|
+
# Sparse Markdown lines (e.g. a one-symbol final line) are not a
|
|
110
|
+
# faithful pixel-height projection. Keep a second density estimate
|
|
111
|
+
# so the candidate window cannot collapse below a short real seam.
|
|
112
|
+
density = max(32, math.ceil(len(norm_view(text)[0]) * fraction * 2))
|
|
113
|
+
if len(lines) > 2:
|
|
114
|
+
count = max(1, math.ceil(len(lines) * fraction) + 1)
|
|
115
|
+
band = "".join(lines[-count:] if tail else lines[:count])
|
|
116
|
+
return max(density, len(norm_view(band)[0]))
|
|
117
|
+
return density
|
|
118
|
+
return {"overlap_bbox": [max(previous[0], current[0]), y0, min(previous[2], current[2]), y1],
|
|
119
|
+
"a_window_chars": window(a, height / max(1, previous[3] - previous[1]), True),
|
|
120
|
+
"b_window_chars": window(b, height / max(1, current[3] - current[1]), False),
|
|
121
|
+
"candidate_window_basis": "monotonic_line_projection_estimate"}
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class LocalOCRBackend(OCRBackend):
|
|
125
|
+
def __init__(self, name: str = "ocr.chain") -> None:
|
|
126
|
+
if name not in {"ocr.chain", "ocr.ovis3", "ocr.minicpm"}:
|
|
127
|
+
raise ValueError("unknown OCR backend")
|
|
128
|
+
self.name = name
|
|
129
|
+
|
|
130
|
+
def recognize(self, source_ref: str, *, title: str) -> TranscriptResult:
|
|
131
|
+
source = local_path_from_input(source_ref)
|
|
132
|
+
if source is None or not source.is_file():
|
|
133
|
+
return self.failed("ocr_local_file_required", title)
|
|
134
|
+
try:
|
|
135
|
+
from PIL import Image
|
|
136
|
+
|
|
137
|
+
with Image.open(source) as raw:
|
|
138
|
+
image = raw.convert("RGB")
|
|
139
|
+
return self.recognize_image(image, title=title)
|
|
140
|
+
except (ImportError, OSError, ValueError) as exc:
|
|
141
|
+
return self.failed("ocr_image_unavailable", title, type(exc).__name__)
|
|
142
|
+
|
|
143
|
+
def failed(self, code: str, title: str, detail: str | None = None) -> TranscriptResult:
|
|
144
|
+
return TranscriptResult(text="", status="failed", source_level="ocr", backend=self.name, title=title, error_code=code, error_summary=detail or code, source_notes=["No model installation/download or Ollama lifecycle action was attempted."], metadata={"profile": "document"})
|
|
145
|
+
|
|
146
|
+
def recognize_image(self, image, *, title: str, page: int = 1, tile: bool = True) -> TranscriptResult:
|
|
147
|
+
try:
|
|
148
|
+
config = settings()
|
|
149
|
+
with gpu_lock(timeout=config["gpu_lock_seconds"]):
|
|
150
|
+
return self._recognize_locked(image, title, page, tile, config)
|
|
151
|
+
except (OSError, ValueError, TimeoutError, OllamaError, GPUQuarantined) as exc:
|
|
152
|
+
code = getattr(exc, "code", None) or (str(exc).split(":", 1)[0] if isinstance(exc, (OllamaError, TimeoutError, GPUQuarantined)) else "ocr_runtime_unavailable")
|
|
153
|
+
if isinstance(exc, GPUQuarantined):
|
|
154
|
+
return TranscriptResult(text="", status="partial", source_level="ocr", backend=self.name, title=title, error_code=code, error_summary=str(exc), metadata={"terminal_partial": True, "abort_file": True, "profile": "document"})
|
|
155
|
+
return self.failed(code, title, str(exc) if isinstance(exc, (OllamaError, TimeoutError)) else type(exc).__name__)
|
|
156
|
+
|
|
157
|
+
def _recognize_locked(self, image, title: str, page: int, tile: bool, config: dict) -> TranscriptResult:
|
|
158
|
+
tags = api_json(config["ollama_url"], "/api/tags")
|
|
159
|
+
primary = "minicpm_model" if self.name == "ocr.minicpm" else "ovis_model"
|
|
160
|
+
model_identity(tags, config[primary])
|
|
161
|
+
started = time.monotonic()
|
|
162
|
+
deadline = started + config["page_seconds"]
|
|
163
|
+
receipts, texts, merge = [], [], {"seams": [], "warnings": [], "arbitration_attempts": []}
|
|
164
|
+
text = ""
|
|
165
|
+
params = {key: config[key] for key in ("tile_height", "tile_overlap", "tile_search", "tile_aspect_ratio")}
|
|
166
|
+
with owned_temporary_directory(prefix="openmd-ocr-tiles-") as tmp:
|
|
167
|
+
directory = Path(tmp)
|
|
168
|
+
if tile and image.height > config["tile_height"] * 1.1 and image.height / image.width > config["tile_aspect_ratio"]:
|
|
169
|
+
source = directory / "input.png"
|
|
170
|
+
image.save(source)
|
|
171
|
+
plan = split_image(source, directory / "tiles", config["tile_height"], config["tile_overlap"], config["tile_search"])
|
|
172
|
+
rectangles = [(0, t["y0"], image.width, t["y1"]) for t in plan["tiles"]]
|
|
173
|
+
params.update(plan["params"])
|
|
174
|
+
else:
|
|
175
|
+
rectangles = [(0, 0, image.width, image.height)]
|
|
176
|
+
for index, box in enumerate(rectangles, 1):
|
|
177
|
+
receipt, text = self._tile(image.crop(box), config, tags, deadline)
|
|
178
|
+
receipt.update({"tile": index, "bbox": list(box)})
|
|
179
|
+
receipts.append(receipt)
|
|
180
|
+
texts.append(text)
|
|
181
|
+
if any(a.get("unsafe_to_switch") for a in receipt["attempts"]):
|
|
182
|
+
text = "\n\n".join(texts)
|
|
183
|
+
break
|
|
184
|
+
if index == 1:
|
|
185
|
+
merged = text
|
|
186
|
+
continue
|
|
187
|
+
if text and texts[-2]:
|
|
188
|
+
geometry = seam_geometry(texts[-2], text, rectangles[index - 2], box)
|
|
189
|
+
merged, seam, arbitration = self._merge_seam(merged, texts[-2], text, image, geometry, config, tags, deadline, receipt)
|
|
190
|
+
seam["seam"] = f"{index - 1}->{index}"
|
|
191
|
+
merge["seams"].append(seam)
|
|
192
|
+
if arbitration is not None:
|
|
193
|
+
merge["arbitration_attempts"].append(arbitration)
|
|
194
|
+
if seam["status"] != "merged":
|
|
195
|
+
merge["warnings"].append(f"seam {index - 1}->{index}: merge_uncertain (localized variants retained where alignment is proven)")
|
|
196
|
+
text = merged
|
|
197
|
+
if arbitration is not None and arbitration.get("unsafe_to_switch"):
|
|
198
|
+
receipt.update({"status": "partial", "reason": "ollama_unload_unconfirmed"})
|
|
199
|
+
break
|
|
200
|
+
else:
|
|
201
|
+
merged += "\n\n" + text
|
|
202
|
+
text = merged
|
|
203
|
+
merge["warnings"].append("One or more tiles have no text; overlap merge was skipped.")
|
|
204
|
+
text = text if texts else ""
|
|
205
|
+
reps = repeated_ngrams(text)
|
|
206
|
+
merge.update({"tiles": len(texts), "repeated_spans": reps[:20], "merged_norm_chars": len(norm_view(text)[0])})
|
|
207
|
+
if reps:
|
|
208
|
+
merge["warnings"].append(f"{len(reps)} repeated 24-char spans in merged text (possible duplicate overlap)")
|
|
209
|
+
partial = not text.strip() or any(r["status"] != "verified" for r in receipts) or bool(merge["warnings"])
|
|
210
|
+
attempts = [attempt for r in receipts for attempt in r["attempts"]] + merge["arbitration_attempts"]
|
|
211
|
+
unsafe = any(a.get("unsafe_to_switch") for a in attempts)
|
|
212
|
+
peaks = [a["peak_vram_mb"] for a in attempts if a.get("peak_vram_mb") is not None]
|
|
213
|
+
reasons = sorted({r["reason"] for r in receipts if r.get("reason")})
|
|
214
|
+
if merge["warnings"]:
|
|
215
|
+
reasons.append("merge_uncertain")
|
|
216
|
+
record = {"page": page, "route": "ocr", "status": "partial" if partial else "verified", "engine": sorted({a["engine"] for a in attempts}), "models": [{"name": a["model"], "digest": a["digest"]} for a in attempts], "fallback_used": any(r["fallback_used"] for r in receipts), "reason": "; ".join(reasons) or None, "ovis_seconds": round(sum(a["seconds"] for a in attempts if a["engine"] == "ocr.ovis3"), 3), "fallback_seconds": round(sum(a["seconds"] for a in attempts if a["engine"] == "ocr.minicpm"), 3), "seconds": round(time.monotonic() - started, 3), "peak_vram_mb": max(peaks) if peaks else None, "peak_vram_scope": "whole_device_observed", "han_ratio": han_ratio(text), "slice_parameters": params, "tiles": receipts, "merge": merge, "breakers": [a["breaker"] for a in attempts if a.get("breaker")]}
|
|
217
|
+
version = "ollama-models:" + hashlib.sha256(json.dumps(record["models"], sort_keys=True).encode()).hexdigest()
|
|
218
|
+
record["unprocessed_tiles"] = len(rectangles) - len(receipts)
|
|
219
|
+
return TranscriptResult(text=text, status=record["status"], source_level="ocr", backend=self.name, title=title, error_code="ollama_unload_unconfirmed" if unsafe else "ocr_partial" if partial else None, source_notes=[*ocr_notes([record]), f"slice_parameters={json.dumps(params, sort_keys=True)}", *merge["warnings"]], metadata={"profile": classify_ocr_profile(text), "pages": [record], "terminal_partial": partial, "abort_file": unsafe, "fallback_used": record["fallback_used"], "tool_run": {"tool_version": version, "tool_version_verified": True}})
|
|
220
|
+
|
|
221
|
+
def _merge_seam(self, merged: str, previous: str, current: str, image, geometry: dict,
|
|
222
|
+
config: dict, tags: dict, deadline: float, receipt: dict):
|
|
223
|
+
text, seam = merge_pair(merged, current, len(norm_view(previous)[0]), config["tile_overlap"], geometry=geometry)
|
|
224
|
+
if seam["status"] == "merged":
|
|
225
|
+
try:
|
|
226
|
+
seam["raw_outputs"] = retain_seam_outputs(previous, current, None, seam, merged_a=merged)
|
|
227
|
+
except OSError:
|
|
228
|
+
seam["raw_outputs"] = {"retained": False}
|
|
229
|
+
return text, seam, None
|
|
230
|
+
attempt, arbitration_text = None, None
|
|
231
|
+
successful = [a for a in receipt["attempts"] if not a.get("breaker") and a.get("request_kind") == "recognition"]
|
|
232
|
+
box = geometry["overlap_bbox"]
|
|
233
|
+
if successful and box[2] > box[0] and box[3] > box[1] and time.monotonic() < deadline:
|
|
234
|
+
# The containing _recognize_locked holds the same OS GPU lease.
|
|
235
|
+
# No chain/fallback call here: one crop, one identical engine.
|
|
236
|
+
attempt, arbitration_text = self._attempt(image.crop(box), config, tags, deadline, successful[-1]["engine"])
|
|
237
|
+
attempt["purpose"] = "seam_arbitration"
|
|
238
|
+
seam["arbitration"] = {"status": "failed" if attempt.get("breaker") else "completed",
|
|
239
|
+
"engine": attempt["engine"], "seconds": attempt["seconds"],
|
|
240
|
+
"bbox": box, "reason": attempt.get("breaker")}
|
|
241
|
+
if not attempt.get("breaker"):
|
|
242
|
+
text, revised = merge_pair(merged, current, len(norm_view(previous)[0]), config["tile_overlap"], geometry=geometry, arbitrator=arbitration_text)
|
|
243
|
+
revised["arbitration"] = seam["arbitration"]
|
|
244
|
+
seam = revised
|
|
245
|
+
else:
|
|
246
|
+
seam["arbitration"] = {"status": "unavailable", "reason": "page_deadline" if time.monotonic() >= deadline else "source_engine_or_overlap_unavailable"}
|
|
247
|
+
try:
|
|
248
|
+
seam["raw_outputs"] = retain_seam_outputs(previous, current, arbitration_text, seam, merged_a=merged)
|
|
249
|
+
except OSError:
|
|
250
|
+
seam["raw_outputs"] = {"retained": False}
|
|
251
|
+
return text, seam, attempt
|
|
252
|
+
|
|
253
|
+
def _attempt(self, image, config: dict, tags: dict, deadline: float, engine: str) -> tuple[dict, str]:
|
|
254
|
+
key = "ovis_model" if engine == "ocr.ovis3" else "minicpm_model"
|
|
255
|
+
identity = model_identity(tags, config[key])
|
|
256
|
+
start = time.monotonic()
|
|
257
|
+
record = {"engine": engine, "model": identity["name"], "digest": identity["digest"], "seconds": 0.0, "peak_vram_mb": None, "breaker": None, "request_kind": "recognition"}
|
|
258
|
+
text = ""
|
|
259
|
+
sampler = None
|
|
260
|
+
own_request_started = False
|
|
261
|
+
# Persist before any attempt, including a hard process termination. Only
|
|
262
|
+
# a confirmed unload can clear this state while the GPU lease is held.
|
|
263
|
+
mark_quarantine(config["ollama_url"], identity["name"])
|
|
264
|
+
try:
|
|
265
|
+
with GpuSampler() as sampler:
|
|
266
|
+
try:
|
|
267
|
+
if engine == "ocr.ovis3":
|
|
268
|
+
prompt = files("openmd.ocr").joinpath("ovis-prompt.txt").read_text(encoding="utf-8")
|
|
269
|
+
own_request_started = True
|
|
270
|
+
output = stream_recognize(image, model=identity["name"], prompt=prompt, config=config, deadline=deadline)
|
|
271
|
+
text = output.pop("text")
|
|
272
|
+
record.update(output)
|
|
273
|
+
else:
|
|
274
|
+
regions, layout_seconds = detect_regions(image, config, deadline)
|
|
275
|
+
blocks, pieces = [], []
|
|
276
|
+
for region in regions:
|
|
277
|
+
if time.monotonic() >= deadline:
|
|
278
|
+
record["breaker"] = {"rule": "page_deadline"}
|
|
279
|
+
break
|
|
280
|
+
category = region["category"].lower()
|
|
281
|
+
block = dict(region)
|
|
282
|
+
if category in {"image", "picture", "chart"}:
|
|
283
|
+
coords = [int(v / (image.width if i % 2 == 0 else image.height) * 1000) for i, v in enumerate(region["bbox"])]
|
|
284
|
+
part = '<img src="images/bbox_' + "_".join(map(str, coords)) + '.jpg" />'
|
|
285
|
+
block["recognition_skipped"] = True
|
|
286
|
+
else:
|
|
287
|
+
prompt = "Table Recognition:" if "table" in category else "Formula Recognition:" if "formula" in category and "number" not in category else "Text Recognition:"
|
|
288
|
+
own_request_started = True
|
|
289
|
+
output = stream_recognize(image.crop(region["bbox"]), model=identity["name"], prompt=prompt, config=config, deadline=deadline)
|
|
290
|
+
part = output.pop("text")
|
|
291
|
+
block.update(output)
|
|
292
|
+
if output.get("breaker"):
|
|
293
|
+
record["breaker"] = output["breaker"]
|
|
294
|
+
if "table" in category and any(tag in part for tag in ("<fcel>", "<ecel>")):
|
|
295
|
+
block["raw_output_sha256"] = hashlib.sha256(part.encode()).hexdigest()
|
|
296
|
+
part = table_html(part)
|
|
297
|
+
if "formula" in category and "number" not in category and part.strip() and not any(mark in part for mark in ("$$", "\\[", "\\begin{")):
|
|
298
|
+
part = "$$\n" + part.strip().strip("$") + "\n$$"
|
|
299
|
+
pieces.append(part)
|
|
300
|
+
blocks.append(block)
|
|
301
|
+
text = "\n\n".join(pieces)
|
|
302
|
+
if record["breaker"]:
|
|
303
|
+
break
|
|
304
|
+
record.update({"blocks": blocks, "layout_seconds": layout_seconds, "detected_regions": len(regions)})
|
|
305
|
+
# The fallback actually reads many crop images. Its
|
|
306
|
+
# capacity is their combined image-only estimate, not
|
|
307
|
+
# the downsampled full-page estimate used by Ovis. The
|
|
308
|
+
# latter undercounts dense newspapers after layout.
|
|
309
|
+
budgets = [b["capacity"] for b in blocks if b.get("capacity")]
|
|
310
|
+
if budgets:
|
|
311
|
+
capacity = {"estimated_chars": sum(b["estimated_chars"] for b in budgets), "max_length_chars": sum(b["max_length_chars"] for b in budgets), "scope": "layout_crop_images"}
|
|
312
|
+
else:
|
|
313
|
+
capacity = estimate_expected_chars(image, config)
|
|
314
|
+
record["capacity"] = capacity
|
|
315
|
+
record["breaker"] = record["breaker"] or breaker_reason(text, capacity, config)
|
|
316
|
+
finally:
|
|
317
|
+
# This also runs on Ctrl+C. A failed unload prevents fallback.
|
|
318
|
+
pending = sys.exc_info()[1]
|
|
319
|
+
canceling = isinstance(pending, (KeyboardInterrupt, SystemExit))
|
|
320
|
+
try:
|
|
321
|
+
if own_request_started:
|
|
322
|
+
unload_model(config, identity["name"])
|
|
323
|
+
elif running_models(api_json(config["ollama_url"], "/api/ps")):
|
|
324
|
+
record["unsafe_to_switch"] = True
|
|
325
|
+
raise OllamaError("gpu_external_busy")
|
|
326
|
+
if canceling or (pending is not None and "cancel_unconfirmed" in str(pending)):
|
|
327
|
+
record["unsafe_to_switch"] = True
|
|
328
|
+
else:
|
|
329
|
+
clear_quarantine()
|
|
330
|
+
except BaseException as exc:
|
|
331
|
+
record["unsafe_to_switch"] = True
|
|
332
|
+
# Retain the pre-inference marker even when another
|
|
333
|
+
# Ctrl+C interrupts cleanup. Preserve cancellation.
|
|
334
|
+
if not canceling:
|
|
335
|
+
if isinstance(exc, (KeyboardInterrupt, SystemExit)):
|
|
336
|
+
raise
|
|
337
|
+
raise OllamaError("ollama_unload_unconfirmed") from exc
|
|
338
|
+
record["peak_vram_mb"] = sampler.peak
|
|
339
|
+
except LayoutUnavailable:
|
|
340
|
+
record["breaker"] = {"rule": "fallback_unavailable"}
|
|
341
|
+
except (OSError, ValueError, TimeoutError, OllamaError) as exc:
|
|
342
|
+
record["breaker"] = {"rule": str(exc).split(":", 1)[0] if isinstance(exc, (OllamaError, TimeoutError)) else "engine_error"}
|
|
343
|
+
if "unload" in str(exc) or "cancel_unconfirmed" in str(exc):
|
|
344
|
+
record["unsafe_to_switch"] = True
|
|
345
|
+
record["seconds"] = round(time.monotonic() - start, 3)
|
|
346
|
+
if sampler is not None:
|
|
347
|
+
record["peak_vram_mb"] = sampler.peak
|
|
348
|
+
if not text.strip() and not record["breaker"]:
|
|
349
|
+
record["breaker"] = {"rule": "empty_output"}
|
|
350
|
+
if record["breaker"]:
|
|
351
|
+
try:
|
|
352
|
+
record["raw_output_path"] = retain_breaker_output(text, record)
|
|
353
|
+
except OSError:
|
|
354
|
+
record["raw_output_retained"] = False
|
|
355
|
+
return record, text
|
|
356
|
+
|
|
357
|
+
def _tile(self, image, config: dict, tags: dict, deadline: float) -> tuple[dict, str]:
|
|
358
|
+
record = {"status": "verified", "fallback_used": False, "reason": None, "attempts": []}
|
|
359
|
+
if time.monotonic() >= deadline:
|
|
360
|
+
return {**record, "status": "partial", "reason": "page_deadline"}, ""
|
|
361
|
+
primary = "ocr.minicpm" if self.name == "ocr.minicpm" else "ocr.ovis3"
|
|
362
|
+
try:
|
|
363
|
+
attempt, text = self._attempt(image, config, tags, deadline, primary)
|
|
364
|
+
except LayoutUnavailable:
|
|
365
|
+
return {**record, "status": "partial", "reason": "fallback_unavailable"}, ""
|
|
366
|
+
record["attempts"].append(attempt)
|
|
367
|
+
if not attempt["breaker"]:
|
|
368
|
+
return record, text
|
|
369
|
+
record.update({"status": "partial", "reason": attempt["breaker"]["rule"]})
|
|
370
|
+
if self.name != "ocr.chain" or attempt.get("unsafe_to_switch"):
|
|
371
|
+
return record, text
|
|
372
|
+
record["fallback_used"] = True
|
|
373
|
+
try:
|
|
374
|
+
fallback, repaired = self._attempt(image, config, tags, deadline, "ocr.minicpm")
|
|
375
|
+
record["attempts"].append(fallback)
|
|
376
|
+
if fallback["breaker"]:
|
|
377
|
+
rule = fallback["breaker"]["rule"]
|
|
378
|
+
record["reason"] += "; " + (rule if rule == "fallback_unavailable" else "fallback_" + rule)
|
|
379
|
+
else:
|
|
380
|
+
record["status"] = "verified"
|
|
381
|
+
return record, repaired or text
|
|
382
|
+
except (LayoutUnavailable, OllamaError):
|
|
383
|
+
record["reason"] += "; fallback_unavailable"
|
|
384
|
+
return record, text
|
openmd/ocr/settings.json
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"ovis_model": "ovis3-bf16",
|
|
3
|
+
"minicpm_model": "minicpm-ocr-f16",
|
|
4
|
+
"repeat_min_chars": 15,
|
|
5
|
+
"repeat_count": 3,
|
|
6
|
+
"length_multiplier": 2.0,
|
|
7
|
+
"num_predict": 8192,
|
|
8
|
+
"num_ctx": 8192,
|
|
9
|
+
"page_seconds": 600.0,
|
|
10
|
+
"gpu_lock_seconds": 600.0,
|
|
11
|
+
"max_pixels": 1003520,
|
|
12
|
+
"tile_height": 1440,
|
|
13
|
+
"tile_aspect_ratio": 2.0,
|
|
14
|
+
"tile_overlap": 0.18,
|
|
15
|
+
"tile_search": 0.15,
|
|
16
|
+
"layout_threshold": 0.5,
|
|
17
|
+
"layout_seconds": 120.0,
|
|
18
|
+
"pdf_dpi": 144,
|
|
19
|
+
"textlayer_min_chars": 20,
|
|
20
|
+
"textlayer_min_printable": 0.95,
|
|
21
|
+
"textlayer_image_coverage": 0.5,
|
|
22
|
+
"textlayer_hidden_ratio": 0.5,
|
|
23
|
+
"textlayer_background_delta": 0.08,
|
|
24
|
+
"office_sparse_chars": 200
|
|
25
|
+
}
|