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/layout.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Bounded external layout worker; never imports torch in the controller."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import subprocess
|
|
7
|
+
import tempfile
|
|
8
|
+
import time
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from openmd.runtime.interpreters import owned_popen, communicate_interruptibly, process_options, terminate_quietly, wait_interruptibly, worker_command, worker_environment
|
|
12
|
+
from .config import layout_runtime
|
|
13
|
+
|
|
14
|
+
from openmd.runtime.tempdirs import owned_temporary_directory
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class LayoutUnavailable(RuntimeError):
|
|
18
|
+
pass
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _run(args: list[str], timeout: float, directory: Path):
|
|
22
|
+
python, _ = layout_runtime()
|
|
23
|
+
if python is None:
|
|
24
|
+
raise LayoutUnavailable("fallback_unavailable: configure ocr_python / OPENMD_OCR_PYTHON")
|
|
25
|
+
environment = worker_environment()
|
|
26
|
+
# Windows CUDA may treat an empty value as unset. -1 hides every device.
|
|
27
|
+
environment["CUDA_VISIBLE_DEVICES"] = "-1"
|
|
28
|
+
with (directory / "layout.log").open("wb") as log:
|
|
29
|
+
process = owned_popen(worker_command(python, *args, module="openmd.ocr.layout_worker"), cwd=directory, env=environment, stdin=subprocess.DEVNULL, stdout=log, stderr=log, **process_options())
|
|
30
|
+
try:
|
|
31
|
+
wait_interruptibly(process, timeout)
|
|
32
|
+
except BaseException:
|
|
33
|
+
terminate_quietly(process)
|
|
34
|
+
raise
|
|
35
|
+
if process.returncode:
|
|
36
|
+
raise LayoutUnavailable("fallback_unavailable: external layout worker failed; check local torch/transformers and PP-DocLayoutV3 weights")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def detect_regions(image, config: dict, deadline: float) -> tuple[list[dict], float]:
|
|
40
|
+
python, model = layout_runtime()
|
|
41
|
+
if python is None or model is None:
|
|
42
|
+
raise LayoutUnavailable("fallback_unavailable: configure ocr_python and a model_roots directory containing layout/PP-DocLayoutV3")
|
|
43
|
+
start = time.monotonic()
|
|
44
|
+
remaining = deadline - start
|
|
45
|
+
if remaining <= 0:
|
|
46
|
+
raise TimeoutError("page_deadline")
|
|
47
|
+
try:
|
|
48
|
+
with owned_temporary_directory(prefix="openmd-layout-") as tmp:
|
|
49
|
+
directory = Path(tmp)
|
|
50
|
+
source, result = directory / "input.png", directory / "result.json"
|
|
51
|
+
image.save(source)
|
|
52
|
+
_run(["--input", str(source), "--model", str(model), "--result", str(result), "--threshold", str(config["layout_threshold"])], min(config["layout_seconds"], remaining), directory)
|
|
53
|
+
if not result.is_file() or result.stat().st_size > 4 * 1024 * 1024:
|
|
54
|
+
raise LayoutUnavailable("fallback_unavailable: invalid layout worker result")
|
|
55
|
+
payload = json.loads(result.read_text(encoding="utf-8"))
|
|
56
|
+
regions = payload.get("regions")
|
|
57
|
+
if payload.get("schema") != "openmd.layout/v1" or not isinstance(regions, list) or not regions:
|
|
58
|
+
raise LayoutUnavailable("fallback_unavailable: no layout regions")
|
|
59
|
+
for region in regions:
|
|
60
|
+
box = region.get("bbox")
|
|
61
|
+
if not isinstance(region.get("category"), str) or not isinstance(box, list) or len(box) != 4 or not all(isinstance(v, int) for v in box) or not (0 <= box[0] < box[2] <= image.width and 0 <= box[1] < box[3] <= image.height):
|
|
62
|
+
raise LayoutUnavailable("fallback_unavailable: invalid layout region")
|
|
63
|
+
return regions, time.monotonic() - start
|
|
64
|
+
except (OSError, ValueError, subprocess.SubprocessError) as exc:
|
|
65
|
+
raise LayoutUnavailable("fallback_unavailable: external layout worker unavailable") from exc
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def probe_layout() -> dict:
|
|
69
|
+
python, model = layout_runtime()
|
|
70
|
+
report = {"python_configured": python is not None, "model_available": model is not None, "ready": False}
|
|
71
|
+
if python is None or model is None:
|
|
72
|
+
return report
|
|
73
|
+
try:
|
|
74
|
+
with owned_temporary_directory(prefix="openmd-layout-probe-") as tmp:
|
|
75
|
+
directory = Path(tmp)
|
|
76
|
+
environment = worker_environment()
|
|
77
|
+
environment["CUDA_VISIBLE_DEVICES"] = "-1"
|
|
78
|
+
process = owned_popen(worker_command(python, "--probe", module="openmd.ocr.layout_worker"), cwd=directory, env=environment, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.PIPE, **process_options())
|
|
79
|
+
try:
|
|
80
|
+
stdout, _ = communicate_interruptibly(process, timeout=15)
|
|
81
|
+
except BaseException:
|
|
82
|
+
terminate_quietly(process)
|
|
83
|
+
raise
|
|
84
|
+
if process.returncode or len(stdout) > 1024 * 1024:
|
|
85
|
+
raise ValueError("layout probe failed")
|
|
86
|
+
payload = json.loads(stdout)
|
|
87
|
+
if payload.get("schema") != "openmd.layout-probe/v1":
|
|
88
|
+
raise ValueError("invalid layout probe")
|
|
89
|
+
report.update(payload)
|
|
90
|
+
except (OSError, ValueError, subprocess.SubprocessError):
|
|
91
|
+
report["error_code"] = "fallback_unavailable"
|
|
92
|
+
return report
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# Copyright (c) 2024 PaddlePaddle Authors. All Rights Reserved.
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
# Verbatim rect-layout functions; polygon branch not used.
|
|
17
|
+
from copy import deepcopy
|
|
18
|
+
from typing import Dict,List,Tuple
|
|
19
|
+
import numpy as np
|
|
20
|
+
|
|
21
|
+
def _compute_pairwise_overlap_small(
|
|
22
|
+
coords: np.ndarray,
|
|
23
|
+
) -> Tuple[np.ndarray, np.ndarray]:
|
|
24
|
+
"""
|
|
25
|
+
Compute pairwise overlap ratio (mode="small") and bbox areas.
|
|
26
|
+
|
|
27
|
+
This only replaces the numeric overlap/area calculation. The caller keeps
|
|
28
|
+
the original sequential dropping order because it affects inline formulas.
|
|
29
|
+
"""
|
|
30
|
+
x1 = coords[:, 0]
|
|
31
|
+
y1 = coords[:, 1]
|
|
32
|
+
x2 = coords[:, 2]
|
|
33
|
+
y2 = coords[:, 3]
|
|
34
|
+
|
|
35
|
+
areas = np.abs((x2 - x1) * (y2 - y1))
|
|
36
|
+
|
|
37
|
+
inter_x1 = np.maximum(x1[:, None], x1[None, :])
|
|
38
|
+
inter_y1 = np.maximum(y1[:, None], y1[None, :])
|
|
39
|
+
inter_x2 = np.minimum(x2[:, None], x2[None, :])
|
|
40
|
+
inter_y2 = np.minimum(y2[:, None], y2[None, :])
|
|
41
|
+
|
|
42
|
+
inter_w = np.maximum(0, inter_x2 - inter_x1)
|
|
43
|
+
inter_h = np.maximum(0, inter_y2 - inter_y1)
|
|
44
|
+
inter_area = inter_w * inter_h
|
|
45
|
+
|
|
46
|
+
small_area = np.minimum(areas[:, None], areas[None, :])
|
|
47
|
+
with np.errstate(divide="ignore", invalid="ignore"):
|
|
48
|
+
overlap = np.where(small_area > 0, inter_area / small_area, 0.0)
|
|
49
|
+
return overlap, areas
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def filter_overlap_boxes(
|
|
53
|
+
layout_det_res: Dict[str, List[Dict]], layout_shape_mode: str
|
|
54
|
+
) -> Dict[str, List[Dict]]:
|
|
55
|
+
"""
|
|
56
|
+
Remove overlapping boxes from layout detection results based on a given overlap ratio.
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
layout_det_res (Dict[str, List[Dict]]): Layout detection result dict containing a 'boxes' list.
|
|
60
|
+
|
|
61
|
+
Returns:
|
|
62
|
+
Dict[str, List[Dict]]: Filtered dict with overlapping boxes removed.
|
|
63
|
+
"""
|
|
64
|
+
layout_det_res_filtered = deepcopy(layout_det_res)
|
|
65
|
+
boxes = [
|
|
66
|
+
box for box in layout_det_res_filtered["boxes"] if box["label"] != "reference"
|
|
67
|
+
]
|
|
68
|
+
if not boxes:
|
|
69
|
+
layout_det_res_filtered["boxes"] = boxes
|
|
70
|
+
return layout_det_res_filtered
|
|
71
|
+
|
|
72
|
+
coords = np.array([box["coordinate"] for box in boxes], dtype=np.float64)
|
|
73
|
+
widths = coords[:, 2] - coords[:, 0]
|
|
74
|
+
heights = coords[:, 3] - coords[:, 1]
|
|
75
|
+
overlap_matrix, areas = _compute_pairwise_overlap_small(coords)
|
|
76
|
+
dropped_indexes = set()
|
|
77
|
+
|
|
78
|
+
for i in range(len(boxes)):
|
|
79
|
+
if widths[i] < 6 or heights[i] < 6:
|
|
80
|
+
dropped_indexes.add(i)
|
|
81
|
+
for j in range(i + 1, len(boxes)):
|
|
82
|
+
if i in dropped_indexes or j in dropped_indexes:
|
|
83
|
+
continue
|
|
84
|
+
overlap_ratio = overlap_matrix[i, j]
|
|
85
|
+
if (
|
|
86
|
+
boxes[i]["label"] == "inline_formula"
|
|
87
|
+
or boxes[j]["label"] == "inline_formula"
|
|
88
|
+
):
|
|
89
|
+
if overlap_ratio > 0.5:
|
|
90
|
+
if boxes[i]["label"] == "inline_formula":
|
|
91
|
+
dropped_indexes.add(i)
|
|
92
|
+
if boxes[j]["label"] == "inline_formula":
|
|
93
|
+
dropped_indexes.add(j)
|
|
94
|
+
continue
|
|
95
|
+
if overlap_ratio > 0.7:
|
|
96
|
+
if layout_shape_mode != "rect" and "polygon_points" in boxes[i]:
|
|
97
|
+
poly_overlap_ratio = calculate_polygon_overlap_ratio(
|
|
98
|
+
boxes[i]["polygon_points"], boxes[j]["polygon_points"], "small"
|
|
99
|
+
)
|
|
100
|
+
if poly_overlap_ratio < 0.7:
|
|
101
|
+
continue
|
|
102
|
+
labels = {boxes[i]["label"], boxes[j]["label"]}
|
|
103
|
+
if labels & {"image", "table", "seal", "chart"} and len(labels) > 1:
|
|
104
|
+
if "table" not in labels or labels <= {
|
|
105
|
+
"table",
|
|
106
|
+
"image",
|
|
107
|
+
"seal",
|
|
108
|
+
"chart",
|
|
109
|
+
}:
|
|
110
|
+
continue
|
|
111
|
+
if areas[i] >= areas[j]:
|
|
112
|
+
dropped_indexes.add(j)
|
|
113
|
+
else:
|
|
114
|
+
dropped_indexes.add(i)
|
|
115
|
+
layout_det_res_filtered["boxes"] = [
|
|
116
|
+
box for idx, box in enumerate(boxes) if idx not in dropped_indexes
|
|
117
|
+
]
|
|
118
|
+
return layout_det_res_filtered
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Offline PP-DocLayoutV3 CPU worker. No OCR model is loaded here."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import importlib.metadata
|
|
7
|
+
import importlib.util
|
|
8
|
+
import json
|
|
9
|
+
import math
|
|
10
|
+
import os
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def detect(image, folder: Path, threshold: float) -> list[dict]:
|
|
15
|
+
import torch
|
|
16
|
+
import transformers
|
|
17
|
+
from .layout_filter import filter_overlap_boxes
|
|
18
|
+
|
|
19
|
+
assert_cpu_only(torch)
|
|
20
|
+
torch.set_num_threads(4)
|
|
21
|
+
processor_class = getattr(transformers, "PPDocLayoutV3ImageProcessorFast", transformers.AutoImageProcessor)
|
|
22
|
+
processor = processor_class.from_pretrained(folder, local_files_only=True, trust_remote_code=False)
|
|
23
|
+
model = transformers.AutoModelForObjectDetection.from_pretrained(folder, local_files_only=True, trust_remote_code=False, torch_dtype=torch.float32).to("cpu").eval()
|
|
24
|
+
inputs = processor(images=image, return_tensors="pt")
|
|
25
|
+
with torch.inference_mode():
|
|
26
|
+
output = model(**inputs)
|
|
27
|
+
result = processor.post_process_object_detection(output, target_sizes=[(image.height, image.width)], threshold=threshold)[0]
|
|
28
|
+
regions = []
|
|
29
|
+
for index, (box, label, score) in enumerate(zip(result["boxes"], result["labels"], result["scores"])):
|
|
30
|
+
left, top, right, bottom = [float(v) for v in box]
|
|
31
|
+
coords = [max(0, math.floor(left)), max(0, math.floor(top)), min(image.width, math.ceil(right)), min(image.height, math.ceil(bottom))]
|
|
32
|
+
if coords[2] <= coords[0] or coords[3] <= coords[1]:
|
|
33
|
+
continue
|
|
34
|
+
labels = model.config.id2label
|
|
35
|
+
category = labels.get(int(label), labels.get(str(int(label)), str(int(label))))
|
|
36
|
+
regions.append({"order": index, "category": category, "bbox": coords, "score": float(score), "label": category, "coordinate": coords})
|
|
37
|
+
# Preserve the learned upstream reading order, including inline formula filtering.
|
|
38
|
+
filtered = filter_overlap_boxes({"boxes": regions}, "rect")["boxes"]
|
|
39
|
+
return [{key: value for key, value in region.items() if key not in {"label", "coordinate"}} for region in filtered]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def assert_cpu_only(torch=None) -> None:
|
|
43
|
+
# Reject a mis-launched worker instead of silently hiding a visible GPU.
|
|
44
|
+
# The controller passes -1: an empty Windows environment value can leave
|
|
45
|
+
# CUDA visible. Even an explicit mask must pass the actual runtime check.
|
|
46
|
+
if os.environ.get("CUDA_VISIBLE_DEVICES") not in {"", "-1"}:
|
|
47
|
+
raise RuntimeError("layout_cpu_only: CUDA_VISIBLE_DEVICES must hide all devices")
|
|
48
|
+
if torch is not None and torch.cuda.is_available():
|
|
49
|
+
raise RuntimeError("layout_cpu_only: CUDA is visible to the layout worker")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def main() -> int:
|
|
53
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
54
|
+
parser.add_argument("--probe", action="store_true")
|
|
55
|
+
parser.add_argument("--input", type=Path)
|
|
56
|
+
parser.add_argument("--model", type=Path)
|
|
57
|
+
parser.add_argument("--result", type=Path)
|
|
58
|
+
parser.add_argument("--threshold", type=float, default=0.5)
|
|
59
|
+
args = parser.parse_args()
|
|
60
|
+
assert_cpu_only()
|
|
61
|
+
os.environ.update({"HF_HUB_OFFLINE": "1", "TRANSFORMERS_OFFLINE": "1", "TOKENIZERS_PARALLELISM": "false"})
|
|
62
|
+
if args.probe:
|
|
63
|
+
modules = {name: importlib.util.find_spec(name) is not None for name in ("torch", "transformers", "PIL", "numpy", "torchvision")}
|
|
64
|
+
cuda_visible = None
|
|
65
|
+
if modules["torch"]:
|
|
66
|
+
import torch
|
|
67
|
+
|
|
68
|
+
assert_cpu_only(torch)
|
|
69
|
+
cuda_visible = torch.cuda.is_available()
|
|
70
|
+
versions = {}
|
|
71
|
+
for name in ("torch", "transformers", "torchvision"):
|
|
72
|
+
try:
|
|
73
|
+
versions[name] = importlib.metadata.version(name)
|
|
74
|
+
except importlib.metadata.PackageNotFoundError:
|
|
75
|
+
versions[name] = None
|
|
76
|
+
print(json.dumps({"schema": "openmd.layout-probe/v1", "ready": all(modules.values()), "modules": modules, "versions": versions, "device": "cpu", "cpu_only": True, "cuda_visible": cuda_visible}))
|
|
77
|
+
return 0
|
|
78
|
+
if args.input is None or args.model is None or args.result is None:
|
|
79
|
+
parser.error("--input, --model and --result required")
|
|
80
|
+
from PIL import Image
|
|
81
|
+
|
|
82
|
+
with Image.open(args.input) as source:
|
|
83
|
+
regions = detect(source.convert("RGB"), args.model, args.threshold)
|
|
84
|
+
args.result.write_text(json.dumps({"schema": "openmd.layout/v1", "regions": regions}), encoding="utf-8")
|
|
85
|
+
return 0
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
if __name__ == "__main__":
|
|
89
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
"""Restore supported DOCX formula structure only with unique native text proof."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from io import BytesIO
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from xml.etree import ElementTree as ET
|
|
9
|
+
from zipfile import BadZipFile, ZipFile
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
_MATH = "{http://schemas.openxmlformats.org/officeDocument/2006/math}"
|
|
13
|
+
_WORD_NAMESPACES = {"http://schemas.openxmlformats.org/wordprocessingml/2006/main",
|
|
14
|
+
"http://purl.oclc.org/ooxml/wordprocessingml/main"}
|
|
15
|
+
_COMPATIBILITY = "{http://schemas.openxmlformats.org/markup-compatibility/2006}"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _xml_source(data):
|
|
19
|
+
"""Retain namespace scopes needed to interpret mc:Ignorable accurately."""
|
|
20
|
+
scopes, pending, ignored = [], {}, {}
|
|
21
|
+
for event, value in ET.iterparse(BytesIO(data), events=("start", "end", "start-ns")):
|
|
22
|
+
if event == "start-ns":
|
|
23
|
+
pending[value[0]] = value[1]
|
|
24
|
+
elif event == "start":
|
|
25
|
+
names = dict(scopes[-1][0]) if scopes else {}
|
|
26
|
+
names.update(pending)
|
|
27
|
+
pending.clear()
|
|
28
|
+
namespaces = set(scopes[-1][1]) if scopes else set()
|
|
29
|
+
namespaces.update(names.get(prefix, "") for prefix in value.get(_COMPATIBILITY + "Ignorable", "").split())
|
|
30
|
+
ignored[value] = value.tag.partition("}")[0].removeprefix("{") in namespaces
|
|
31
|
+
scopes.append((names, namespaces))
|
|
32
|
+
else:
|
|
33
|
+
scopes.pop()
|
|
34
|
+
return value, ignored
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _permitted_math_sources(roots, ignored) -> list[str]:
|
|
38
|
+
"""Prove the entire ancestry, not just absence of known unsafe wrappers.
|
|
39
|
+
|
|
40
|
+
This intentionally accepts a small rendering grammar. Properties, extension
|
|
41
|
+
wrappers and other parts cannot acquire identity from matching PDF digits.
|
|
42
|
+
Header/footer math is checked but only main-document math is restored.
|
|
43
|
+
"""
|
|
44
|
+
reasons = set()
|
|
45
|
+
for part, root in roots.items():
|
|
46
|
+
if not any(n.tag.rsplit('}', 1)[-1] in {"oMath", "oMathPara"} for n in root.iter()):
|
|
47
|
+
continue
|
|
48
|
+
expected = "document" if part == "word/document.xml" else (
|
|
49
|
+
"hdr" if re.fullmatch(r"word/header[0-9]+\.xml", part) else
|
|
50
|
+
"ftr" if re.fullmatch(r"word/footer[0-9]+\.xml", part) else None)
|
|
51
|
+
namespace = next((ns for ns in _WORD_NAMESPACES if root.tag == "{" + ns + "}" + str(expected)), None)
|
|
52
|
+
if expected is None or namespace is None:
|
|
53
|
+
reasons.add("omml_unapproved_part_or_root")
|
|
54
|
+
continue
|
|
55
|
+
word = "{" + namespace + "}"
|
|
56
|
+
# Every edge on the path to an equation must be explicitly recognized.
|
|
57
|
+
edges = {
|
|
58
|
+
word + "document": {word + "body"},
|
|
59
|
+
**{word + n: {word + "p", word + "tbl"} for n in ("body", "hdr", "ftr", "tc")},
|
|
60
|
+
word + "tbl": {word + "tr"}, word + "tr": {word + "tc"},
|
|
61
|
+
word + "p": {word + "r", _MATH + "oMathPara", _MATH + "oMath"},
|
|
62
|
+
# Inline (run-level) oMath is a sibling of w:r inside w:p. CT_R
|
|
63
|
+
# itself is not an equation container.
|
|
64
|
+
_MATH + "oMathPara": {_MATH + "oMath"},
|
|
65
|
+
}
|
|
66
|
+
def visit(node, permitted):
|
|
67
|
+
if node.tag.rsplit('}', 1)[-1] in {"oMath", "oMathPara"}:
|
|
68
|
+
if not permitted or node.tag not in {_MATH + "oMath", _MATH + "oMathPara"}:
|
|
69
|
+
reasons.add("omml_unapproved_ancestor")
|
|
70
|
+
for child in node:
|
|
71
|
+
visit(child, permitted and child.tag in edges.get(node.tag, set()) and not ignored.get(child, False))
|
|
72
|
+
visit(root, not ignored.get(root, False))
|
|
73
|
+
return sorted(reasons)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _ambiguous_rendering(roots) -> list[str]:
|
|
77
|
+
"""A source-wide veto avoids guessing a converter's rendered branch."""
|
|
78
|
+
reasons = set()
|
|
79
|
+
for root in roots:
|
|
80
|
+
for node in root.iter():
|
|
81
|
+
if node.tag == _COMPATIBILITY + "AlternateContent":
|
|
82
|
+
reasons.add("alternate_content")
|
|
83
|
+
for namespace in _WORD_NAMESPACES:
|
|
84
|
+
prefix = "{" + namespace + "}"
|
|
85
|
+
if not node.tag.startswith(prefix):
|
|
86
|
+
continue
|
|
87
|
+
kind = node.tag.removeprefix(prefix)
|
|
88
|
+
if kind in {"ins", "del"} or kind.startswith(("moveFrom", "moveTo")) or kind.endswith("PrChange"):
|
|
89
|
+
reasons.add("revision_markup")
|
|
90
|
+
if kind in {"fldSimple", "fldChar", "instrText", "delInstrText"}:
|
|
91
|
+
reasons.add("field_code")
|
|
92
|
+
if kind in {"vanish", "webHidden"}:
|
|
93
|
+
reasons.add("hidden_content")
|
|
94
|
+
if kind in {"altChunk", "subDoc", "dataBinding"}:
|
|
95
|
+
reasons.add("dynamic_content")
|
|
96
|
+
return sorted(reasons)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _formula(node) -> tuple[str, str]:
|
|
100
|
+
"""Return extraction-order characters and explicit source math structure.
|
|
101
|
+
|
|
102
|
+
This deliberately supports a small OMML subset. Unknown structures cannot
|
|
103
|
+
be flattened speculatively. Formatting properties do not supply characters.
|
|
104
|
+
"""
|
|
105
|
+
kind = node.tag.removeprefix(_MATH)
|
|
106
|
+
|
|
107
|
+
def child(name):
|
|
108
|
+
matches = node.findall(_MATH + name)
|
|
109
|
+
if len(matches) != 1:
|
|
110
|
+
raise ValueError("missing_or_duplicate_math_component")
|
|
111
|
+
return _formula(matches[0])
|
|
112
|
+
|
|
113
|
+
if node.tag == _MATH + "t":
|
|
114
|
+
value = node.text or ""
|
|
115
|
+
# Do not interpret literal TeX syntax from a text run as markup.
|
|
116
|
+
if any(c in value for c in "\\{}$&#_%^"):
|
|
117
|
+
raise ValueError("literal_math_markup")
|
|
118
|
+
return value, value
|
|
119
|
+
containers = {"oMath", "r", "e", "sub", "sup", "num", "den"}
|
|
120
|
+
components = {"sSub": {"e", "sub", "sSubPr"}, "sSup": {"e", "sup", "sSupPr"},
|
|
121
|
+
"sSubSup": {"e", "sub", "sup", "sSubSupPr"},
|
|
122
|
+
"f": {"num", "den", "fPr"}, "nary": {"sub", "sup", "e", "naryPr"}}
|
|
123
|
+
if kind not in containers | components.keys() or not node.tag.startswith(_MATH):
|
|
124
|
+
raise ValueError("unsupported_math_structure")
|
|
125
|
+
if kind in components and any(c.tag not in {_MATH + n for n in components[kind]} for c in node):
|
|
126
|
+
raise ValueError("unsupported_math_component")
|
|
127
|
+
if kind in {"sSub", "sSup", "sSubSup"}:
|
|
128
|
+
plain, markup = child("e")
|
|
129
|
+
for name, operator in (("sub", "_"), ("sup", "^")):
|
|
130
|
+
if name == "sub" and kind == "sSup" or name == "sup" and kind == "sSub":
|
|
131
|
+
continue
|
|
132
|
+
raw, rendered = child(name)
|
|
133
|
+
plain += raw
|
|
134
|
+
markup += operator + "{" + rendered + "}"
|
|
135
|
+
return plain, markup
|
|
136
|
+
if kind == "f":
|
|
137
|
+
fraction_type = node.find(_MATH + "fPr/" + _MATH + "type")
|
|
138
|
+
if fraction_type is not None and fraction_type.get(_MATH + "val") != "bar":
|
|
139
|
+
raise ValueError("unsupported_fraction_type")
|
|
140
|
+
numerator, denominator = child("num"), child("den")
|
|
141
|
+
return numerator[0] + denominator[0], "\\frac{" + numerator[1] + "}{" + denominator[1] + "}"
|
|
142
|
+
if kind == "nary":
|
|
143
|
+
symbol = node.find(_MATH + "naryPr/" + _MATH + "chr")
|
|
144
|
+
symbol = symbol.get(_MATH + "val", "") if symbol is not None else ""
|
|
145
|
+
if symbol not in {"∑", "∏", "∫", "∬", "∭", "⋃", "⋂"}:
|
|
146
|
+
raise ValueError("unsupported_nary_symbol")
|
|
147
|
+
lower, upper, expression = child("sub"), child("sup"), child("e")
|
|
148
|
+
return symbol + lower[0] + upper[0] + expression[0], symbol + "_{" + lower[1] + "}^{" + upper[1] + "}" + expression[1]
|
|
149
|
+
pairs = [_formula(c) for c in node if c.tag not in {
|
|
150
|
+
_MATH + "rPr", "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}rPr"}]
|
|
151
|
+
return "".join(p[0] for p in pairs), "".join(p[1] for p in pairs)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def restore_docx_math(path: Path, text: str) -> tuple[str, dict]:
|
|
155
|
+
"""Never change characters without a unique, whitespace-only native match.
|
|
156
|
+
|
|
157
|
+
Caller must already have proved every output page's text layer reliable.
|
|
158
|
+
No OCR, URL resolution, normalization, numerical correction or guessing.
|
|
159
|
+
"""
|
|
160
|
+
receipt = {"source": "docx_omml", "formulas": 0, "restored": 0, "unmatched": 0, "unsupported": 0,
|
|
161
|
+
"match_rule": "unique_raw_characters_ignoring_whitespace_within_paragraph"}
|
|
162
|
+
try:
|
|
163
|
+
with ZipFile(path) as archive:
|
|
164
|
+
roots, ignored = {}, {}
|
|
165
|
+
names = archive.namelist()
|
|
166
|
+
if len(names) != len(set(names)):
|
|
167
|
+
receipt.update(disabled=True, reason="ambiguous_source_parts", disabled_reasons=["duplicate_source_part"])
|
|
168
|
+
return text, receipt
|
|
169
|
+
for name in names:
|
|
170
|
+
data = archive.read(name)
|
|
171
|
+
# OPC XML parts need not use .xml names. Unrecognized XML can
|
|
172
|
+
# contain equations too; media bytes grant no source identity.
|
|
173
|
+
looks_xml = data.lstrip().startswith((b"<", b"\xef\xbb\xbf", b"\xff\xfe", b"\xfe\xff"))
|
|
174
|
+
if name.lower().endswith((".xml", ".vml")) or looks_xml:
|
|
175
|
+
roots[name], scope = _xml_source(data)
|
|
176
|
+
ignored.update(scope)
|
|
177
|
+
root = roots["word/document.xml"]
|
|
178
|
+
except (OSError, BadZipFile, KeyError, ET.ParseError):
|
|
179
|
+
receipt["source_unavailable"] = True
|
|
180
|
+
return text, receipt
|
|
181
|
+
reasons = _ambiguous_rendering(roots.values())
|
|
182
|
+
if reasons:
|
|
183
|
+
receipt.update({"disabled": True, "reason": "ambiguous_document_rendering", "disabled_reasons": reasons})
|
|
184
|
+
return text, receipt
|
|
185
|
+
reasons = _permitted_math_sources(roots, ignored)
|
|
186
|
+
if reasons:
|
|
187
|
+
receipt.update({"disabled": True, "reason": "unapproved_omml_rendering_path", "disabled_reasons": reasons})
|
|
188
|
+
return text, receipt
|
|
189
|
+
offsets = [i for i, c in enumerate(text) if not c.isspace()]
|
|
190
|
+
compact = "".join(text[i] for i in offsets)
|
|
191
|
+
replacements = []
|
|
192
|
+
for node in root.iter(_MATH + "oMath"):
|
|
193
|
+
receipt["formulas"] += 1
|
|
194
|
+
try:
|
|
195
|
+
plain, markup = _formula(node)
|
|
196
|
+
except ValueError:
|
|
197
|
+
receipt["unsupported"] += 1
|
|
198
|
+
continue
|
|
199
|
+
needle = "".join(c for c in plain if not c.isspace())
|
|
200
|
+
start = compact.find(needle)
|
|
201
|
+
if len(needle) < 4 or start < 0 or compact.find(needle, start + 1) >= 0:
|
|
202
|
+
receipt["unmatched"] += 1
|
|
203
|
+
continue
|
|
204
|
+
end = start + len(needle)
|
|
205
|
+
# Native PDF extraction uses blank lines for paragraph boundaries.
|
|
206
|
+
# Whitespace-insensitive matching must never bridge those boundaries
|
|
207
|
+
# (or a page break), even if the combined characters occur only once.
|
|
208
|
+
candidate = text[offsets[start]:offsets[end - 1] + 1]
|
|
209
|
+
if re.search(r"\n[ \t\r]*\n|\f", candidate):
|
|
210
|
+
receipt["unmatched"] += 1
|
|
211
|
+
receipt["paragraph_boundary_rejections"] = receipt.get("paragraph_boundary_rejections", 0) + 1
|
|
212
|
+
continue
|
|
213
|
+
replacements.append((offsets[start], offsets[end - 1] + 1, "$" + markup + "$"))
|
|
214
|
+
# Repeated source formulas or nested/overlapping matches are not unique proof.
|
|
215
|
+
unique = [r for r in replacements if sum(r[0] < s[1] and s[0] < r[1] for s in replacements) == 1]
|
|
216
|
+
receipt["unmatched"] += len(replacements) - len(unique)
|
|
217
|
+
for start, end, replacement in sorted(unique, reverse=True):
|
|
218
|
+
text = text[:start] + replacement + text[end:]
|
|
219
|
+
receipt["restored"] += 1
|
|
220
|
+
return text, receipt
|