orbitkit 0.8.78__tar.gz → 0.9.10__tar.gz

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.
Files changed (94) hide show
  1. {orbitkit-0.8.78/orbitkit.egg-info → orbitkit-0.9.10}/PKG-INFO +1 -1
  2. orbitkit-0.9.10/orbitkit/VERSION +1 -0
  3. {orbitkit-0.8.78 → orbitkit-0.9.10}/orbitkit/airflow_handler/data_preprocessing.py +1 -0
  4. {orbitkit-0.8.78 → orbitkit-0.9.10}/orbitkit/airflow_handler/file_flow_entry_process.py +15 -0
  5. orbitkit-0.9.10/orbitkit/cdn_uploader/__init__.py +16 -0
  6. orbitkit-0.9.10/orbitkit/cdn_uploader/uploader.py +151 -0
  7. orbitkit-0.9.10/orbitkit/pdf_transformer/__init__.py +16 -0
  8. orbitkit-0.9.10/orbitkit/pdf_transformer/mineru/__init__.py +41 -0
  9. orbitkit-0.9.10/orbitkit/pdf_transformer/mineru/bbox_rotation.py +218 -0
  10. orbitkit-0.9.10/orbitkit/pdf_transformer/mineru/result_transformer.py +687 -0
  11. orbitkit-0.9.10/orbitkit/pdf_validator/__init__.py +35 -0
  12. orbitkit-0.9.10/orbitkit/pdf_validator/filters/__init__.py +1 -0
  13. orbitkit-0.9.10/orbitkit/pdf_validator/filters/drop_non_text.py +19 -0
  14. orbitkit-0.9.10/orbitkit/pdf_validator/filters/drop_vlm_artifacts.py +69 -0
  15. orbitkit-0.9.10/orbitkit/pdf_validator/filters/repeated_text.py +128 -0
  16. orbitkit-0.9.10/orbitkit/pdf_validator/pipeline.py +103 -0
  17. orbitkit-0.9.10/orbitkit/pdf_validator/registry.py +64 -0
  18. {orbitkit-0.8.78 → orbitkit-0.9.10/orbitkit.egg-info}/PKG-INFO +1 -1
  19. {orbitkit-0.8.78 → orbitkit-0.9.10}/orbitkit.egg-info/SOURCES.txt +13 -0
  20. orbitkit-0.8.78/orbitkit/VERSION +0 -1
  21. {orbitkit-0.8.78 → orbitkit-0.9.10}/LICENSE +0 -0
  22. {orbitkit-0.8.78 → orbitkit-0.9.10}/MANIFEST.in +0 -0
  23. {orbitkit-0.8.78 → orbitkit-0.9.10}/README.md +0 -0
  24. {orbitkit-0.8.78 → orbitkit-0.9.10}/orbitkit/__init__.py +0 -0
  25. {orbitkit-0.8.78 → orbitkit-0.9.10}/orbitkit/airflow_handler/__init__.py +0 -0
  26. {orbitkit-0.8.78 → orbitkit-0.9.10}/orbitkit/airflow_handler/file_flow_exit_process.py +0 -0
  27. {orbitkit-0.8.78 → orbitkit-0.9.10}/orbitkit/airflow_handler/file_handler.py +0 -0
  28. {orbitkit-0.8.78 → orbitkit-0.9.10}/orbitkit/airflow_handler/file_handler_v2.py +0 -0
  29. {orbitkit-0.8.78 → orbitkit-0.9.10}/orbitkit/audio_transcoder/__init__.py +0 -0
  30. {orbitkit-0.8.78 → orbitkit-0.9.10}/orbitkit/audio_transcoder/netmind_extract_v1.py +0 -0
  31. {orbitkit-0.8.78 → orbitkit-0.9.10}/orbitkit/constant/__init__.py +0 -0
  32. {orbitkit-0.8.78 → orbitkit-0.9.10}/orbitkit/constant/report_schema.py +0 -0
  33. {orbitkit-0.8.78 → orbitkit-0.9.10}/orbitkit/id_srv/__init__.py +0 -0
  34. {orbitkit-0.8.78 → orbitkit-0.9.10}/orbitkit/id_srv/id_gen.py +0 -0
  35. {orbitkit-0.8.78 → orbitkit-0.9.10}/orbitkit/id_srv/id_perm_like.py +0 -0
  36. {orbitkit-0.8.78 → orbitkit-0.9.10}/orbitkit/lark_send/__init__.py +0 -0
  37. {orbitkit-0.8.78 → orbitkit-0.9.10}/orbitkit/lark_send/lark.py +0 -0
  38. {orbitkit-0.8.78 → orbitkit-0.9.10}/orbitkit/llm_tools/__init__.py +0 -0
  39. {orbitkit-0.8.78 → orbitkit-0.9.10}/orbitkit/llm_tools/quick_rag_chat.py +0 -0
  40. {orbitkit-0.8.78 → orbitkit-0.9.10}/orbitkit/orbit_type/__init__.py +0 -0
  41. {orbitkit-0.8.78 → orbitkit-0.9.10}/orbitkit/orbit_type/doc_4_compile_rule.py +0 -0
  42. {orbitkit-0.8.78 → orbitkit-0.9.10}/orbitkit/orbit_type/orbit_type_simple.py +0 -0
  43. {orbitkit-0.8.78 → orbitkit-0.9.10}/orbitkit/orbit_type/tools.py +0 -0
  44. {orbitkit-0.8.78 → orbitkit-0.9.10}/orbitkit/pdf_embedding/__init__.py +0 -0
  45. {orbitkit-0.8.78 → orbitkit-0.9.10}/orbitkit/pdf_embedding/pdf_txt_embedding.py +0 -0
  46. {orbitkit-0.8.78 → orbitkit-0.9.10}/orbitkit/pdf_embedding/pdf_txt_embedding_v2.py +0 -0
  47. {orbitkit-0.8.78 → orbitkit-0.9.10}/orbitkit/pdf_extractor/__init__.py +0 -0
  48. {orbitkit-0.8.78 → orbitkit-0.9.10}/orbitkit/pdf_extractor/a_stock_extractor_v1.py +0 -0
  49. {orbitkit-0.8.78 → orbitkit-0.9.10}/orbitkit/pdf_extractor/exceptions.py +0 -0
  50. {orbitkit-0.8.78 → orbitkit-0.9.10}/orbitkit/pdf_extractor/mineru_demo.py +0 -0
  51. {orbitkit-0.8.78 → orbitkit-0.9.10}/orbitkit/pdf_extractor/pdf_block_extractor_base.py +0 -0
  52. {orbitkit-0.8.78 → orbitkit-0.9.10}/orbitkit/pdf_extractor/pdf_block_extractor_v1.py +0 -0
  53. {orbitkit-0.8.78 → orbitkit-0.9.10}/orbitkit/pdf_extractor/pdf_block_extractor_v2.py +0 -0
  54. {orbitkit-0.8.78 → orbitkit-0.9.10}/orbitkit/pdf_extractor/pdf_extractor_azure.py +0 -0
  55. {orbitkit-0.8.78 → orbitkit-0.9.10}/orbitkit/pdf_extractor/pdf_extractor_minerU_v1.py +0 -0
  56. {orbitkit-0.8.78 → orbitkit-0.9.10}/orbitkit/pdf_extractor/pdf_extractor_netmind_v1.py +0 -0
  57. {orbitkit-0.8.78 → orbitkit-0.9.10}/orbitkit/pdf_extractor/pdf_extractor_netmind_v2.py +0 -0
  58. {orbitkit-0.8.78 → orbitkit-0.9.10}/orbitkit/pdf_extractor/pdf_extractor_netmind_v3.py +0 -0
  59. {orbitkit-0.8.78 → orbitkit-0.9.10}/orbitkit/pdf_extractor/pdf_extractor_orbit.py +0 -0
  60. {orbitkit-0.8.78 → orbitkit-0.9.10}/orbitkit/pdf_extractor_simple/__init__.py +0 -0
  61. {orbitkit-0.8.78 → orbitkit-0.9.10}/orbitkit/pdf_extractor_simple/base.py +0 -0
  62. {orbitkit-0.8.78 → orbitkit-0.9.10}/orbitkit/pdf_extractor_simple/cloud_provider.py +0 -0
  63. {orbitkit-0.8.78 → orbitkit-0.9.10}/orbitkit/pdf_extractor_simple/core.py +0 -0
  64. {orbitkit-0.8.78 → orbitkit-0.9.10}/orbitkit/pdf_extractor_simple/exceptions.py +0 -0
  65. {orbitkit-0.8.78 → orbitkit-0.9.10}/orbitkit/pdf_extractor_simple/extractors.py +0 -0
  66. {orbitkit-0.8.78 → orbitkit-0.9.10}/orbitkit/pdf_extractor_simple/utils.py +0 -0
  67. {orbitkit-0.8.78 → orbitkit-0.9.10}/orbitkit/pdf_writer/__init__.py +0 -0
  68. {orbitkit-0.8.78 → orbitkit-0.9.10}/orbitkit/pdf_writer/pdf_writer_simple.py +0 -0
  69. {orbitkit-0.8.78 → orbitkit-0.9.10}/orbitkit/util/__init__.py +0 -0
  70. {orbitkit-0.8.78 → orbitkit-0.9.10}/orbitkit/util/cache_asset_downloader.py +0 -0
  71. {orbitkit-0.8.78 → orbitkit-0.9.10}/orbitkit/util/common.py +0 -0
  72. {orbitkit-0.8.78 → orbitkit-0.9.10}/orbitkit/util/customize_regix_manager.py +0 -0
  73. {orbitkit-0.8.78 → orbitkit-0.9.10}/orbitkit/util/is_xbrl_structure.py +0 -0
  74. {orbitkit-0.8.78 → orbitkit-0.9.10}/orbitkit/util/secret_manager.py +0 -0
  75. {orbitkit-0.8.78 → orbitkit-0.9.10}/orbitkit/util/universal_extractor.py +0 -0
  76. {orbitkit-0.8.78 → orbitkit-0.9.10}/orbitkit/util/util_aliyun.py +0 -0
  77. {orbitkit-0.8.78 → orbitkit-0.9.10}/orbitkit/util/util_aliyun_oss_simple.py +0 -0
  78. {orbitkit-0.8.78 → orbitkit-0.9.10}/orbitkit/util/util_aws.py +0 -0
  79. {orbitkit-0.8.78 → orbitkit-0.9.10}/orbitkit/util/util_aws_s3_wrapper.py +0 -0
  80. {orbitkit-0.8.78 → orbitkit-0.9.10}/orbitkit/util/util_date.py +0 -0
  81. {orbitkit-0.8.78 → orbitkit-0.9.10}/orbitkit/util/util_html.py +0 -0
  82. {orbitkit-0.8.78 → orbitkit-0.9.10}/orbitkit/util/util_kafka.py +0 -0
  83. {orbitkit-0.8.78 → orbitkit-0.9.10}/orbitkit/util/util_md5.py +0 -0
  84. {orbitkit-0.8.78 → orbitkit-0.9.10}/orbitkit/util/util_selenium.py +0 -0
  85. {orbitkit-0.8.78 → orbitkit-0.9.10}/orbitkit/util/util_simple_timer.py +0 -0
  86. {orbitkit-0.8.78 → orbitkit-0.9.10}/orbitkit/util/util_str.py +0 -0
  87. {orbitkit-0.8.78 → orbitkit-0.9.10}/orbitkit/util/util_type_mapping.py +0 -0
  88. {orbitkit-0.8.78 → orbitkit-0.9.10}/orbitkit/util/util_url.py +0 -0
  89. {orbitkit-0.8.78 → orbitkit-0.9.10}/orbitkit.egg-info/dependency_links.txt +0 -0
  90. {orbitkit-0.8.78 → orbitkit-0.9.10}/orbitkit.egg-info/not-zip-safe +0 -0
  91. {orbitkit-0.8.78 → orbitkit-0.9.10}/orbitkit.egg-info/requires.txt +0 -0
  92. {orbitkit-0.8.78 → orbitkit-0.9.10}/orbitkit.egg-info/top_level.txt +0 -0
  93. {orbitkit-0.8.78 → orbitkit-0.9.10}/setup.cfg +0 -0
  94. {orbitkit-0.8.78 → orbitkit-0.9.10}/setup.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: orbitkit
3
- Version: 0.8.78
3
+ Version: 0.9.10
4
4
  Summary: This project is only for Orbit Tech internal use.
5
5
  Home-page: https://github.com/clown-0726/orbitkit
6
6
  Author: Lilu Cao
@@ -0,0 +1 @@
1
+ 0.9.10
@@ -160,6 +160,7 @@ class DocumentProcessor:
160
160
  "title": title,
161
161
  "published": doc['x_reported_at_utc_date'],
162
162
  "tickers": [],
163
+ "tickers_map_arr": [],
163
164
  "perm_id_list": doc['x_orbit_data']['perm_id_list'],
164
165
  "report_type_id_list_str": doc['x_orbit_data']['report_type_id_list']
165
166
  }
@@ -29,6 +29,7 @@ class FilingOfficialProcessor:
29
29
 
30
30
  self.mongo_client = pymongo.MongoClient(mongo_uri)
31
31
  self.data_xbrl_convert_collection = self.mongo_client['filing_reports']['data_xbrl_convert']
32
+ self.sm_orbit_entity_id_cache_collection = self.mongo_client['stat_cache']['sm_orbit_entity_id_cache']
32
33
 
33
34
  postgres_uri = os.environ.get('PG_URI_AIRFLOW12_USER_NEWSFEEDSITE') if not postgres_uri else postgres_uri
34
35
  if not postgres_uri:
@@ -139,13 +140,27 @@ class FilingOfficialProcessor:
139
140
 
140
141
  if row.ticker is not None:
141
142
  orbit_entity_id_ticker_map[row.orbit_entity_id].append(row.ticker)
143
+ # 批量查询 organization_name / primary_ticker(走查 doc 的同一个 mongo 连接)
144
+ orbit_entity_id_org_map = {}
145
+ cache_cursor = self.sm_orbit_entity_id_cache_collection.find(
146
+ {'orbit_entity_id': {'$in': perm_id_list}},
147
+ {'orbit_entity_id': 1, 'organization_name': 1, 'primary_ticker': 1}
148
+ ).batch_size(self.max_batch_size)
149
+ for row in cache_cursor:
150
+ orbit_entity_id_org_map[row['orbit_entity_id']] = (row.get('organization_name'), row.get('primary_ticker'))
151
+
142
152
  for step_info, records in file_flow_info.items():
143
153
  for record in records:
144
154
  if 'extends' in record and record.get('extends') is not None:
145
155
  tickers = set()
156
+ tickers_map_arr = []
146
157
  for i in record['extends']['perm_id_list']:
147
158
  tickers.update(orbit_entity_id_ticker_map.get(i, []))
159
+ org_info = orbit_entity_id_org_map.get(i)
160
+ if org_info and org_info[0] is not None:
161
+ tickers_map_arr.append({org_info[0]: org_info[1]})
148
162
  record['extends']['tickers'] = list(tickers)
163
+ record['extends']['tickers_map_arr'] = tickers_map_arr
149
164
 
150
165
  record['extends']['report_type_id_list_str'] = [self.report_type_id_name_map.get(i) for i in record['extends']['report_type_id_list_str']]
151
166
 
@@ -0,0 +1,16 @@
1
+ """cdn_uploader:经自建 CDN 网关做对象下载 / 上传(基于 requests,不用 boto3)。
2
+
3
+ 用法::
4
+
5
+ from orbitkit.cdn_uploader import CdnUploader
6
+
7
+ with CdnUploader("https://oassets.orbitfin.ai", upload_key=KEY) as cdn:
8
+ cdn.upload_files("filing-reports", "txt-vector/<key>", result.to_files())
9
+ """
10
+
11
+ from .uploader import DEFAULT_CONTENT_TYPE, CdnUploader
12
+
13
+ __all__ = [
14
+ "CdnUploader",
15
+ "DEFAULT_CONTENT_TYPE",
16
+ ]
@@ -0,0 +1,151 @@
1
+ """自建 CDN 网关客户端(省 S3 出站费),基于 ``requests``,不依赖 boto3。
2
+
3
+ 设了 CDN 网关(形如 ``https://oassets.orbitfin.ai``,路径 ``/{bucket}/{key}``)
4
+ 后,把对象的下载 / 上传改走网关而不是直连 S3。收益:S3→CDN 回源免费 +
5
+ CDN 出站费率低 / 有免费额度 → 省 egress。
6
+
7
+ 用**独立的 requests.Session(trust_env=False,不读全局环境代理)**,是否走代理
8
+ 由 ``proxies`` 显式控制:
9
+ - 传了 ``proxies`` → CDN 请求经该代理出境(例如 xproxy 开启时跟着走 xproxy);
10
+ - 不传 → 直连网关域名。
11
+
12
+ 接口(与生产网关约定一致):
13
+ 下载 GET {base}/{bucket}/{key}
14
+ - 带 ``x-upload-key`` 头(网关不需要会忽略)
15
+ - 成功 2xx,body = 原始字节
16
+ 上传 PUT {base}/{bucket}/{key}
17
+ - ``x-upload-key``: 鉴权 key(**绝不硬编码**,由调用方传入)
18
+ - ``x-amz-content-sha256``: body 的 sha256 十六进制小写
19
+ - ``Content-Type``
20
+ - body = 文件内容
21
+ - 成功 2xx
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import hashlib
27
+ import logging
28
+ from typing import Dict, Mapping, Optional
29
+ from urllib.parse import quote
30
+
31
+ import requests
32
+
33
+ log = logging.getLogger(__name__)
34
+
35
+ DEFAULT_CONTENT_TYPE = "application/octet-stream"
36
+
37
+
38
+ def _proxies_arg(proxies: Optional[Mapping[str, str]], proxy_url: Optional[str]):
39
+ if proxies:
40
+ return dict(proxies)
41
+ if proxy_url:
42
+ return {"http": proxy_url, "https": proxy_url}
43
+ return None
44
+
45
+
46
+ class CdnUploader:
47
+ """经自建 CDN 网关做对象的下载 / 上传(同步)。
48
+
49
+ Args:
50
+ base_url: 网关根地址,如 ``https://oassets.orbitfin.ai``。
51
+ upload_key: 网关鉴权 key(``x-upload-key``);下载时也一并带上(网关
52
+ 不需要会忽略)。**绝不硬编码**,由调用方从环境 / secret 传入。
53
+ timeout_seconds: 单次请求超时。
54
+ proxy_url: 简写:出境代理 URL(同时用于 http/https)。
55
+ proxies: requests 风格的 ``{"http": ..., "https": ...}``;给了则优先于
56
+ ``proxy_url``。两者都不给 = 直连。
57
+ """
58
+
59
+ def __init__(
60
+ self,
61
+ base_url: str,
62
+ upload_key: Optional[str] = None,
63
+ *,
64
+ timeout_seconds: int = 300,
65
+ proxy_url: Optional[str] = None,
66
+ proxies: Optional[Mapping[str, str]] = None,
67
+ ) -> None:
68
+ if not base_url:
69
+ raise ValueError("CdnUploader requires a non-empty base_url")
70
+ self._base = base_url.rstrip("/")
71
+ self._upload_key = upload_key
72
+ self._timeout = float(timeout_seconds)
73
+ self._proxies = _proxies_arg(proxies, proxy_url)
74
+ self._session = requests.Session()
75
+ # trust_env=False:不读全局 HTTP_PROXY/HTTPS_PROXY / netrc;代理只在
76
+ # 显式给了 proxies/proxy_url 时对这个 session 生效。
77
+ self._session.trust_env = False
78
+
79
+ def _url(self, bucket: str, key: str) -> str:
80
+ # key 里的 '/' 是路径分隔符要保留;其它特殊字符做 URL 编码。
81
+ safe_key = quote(key.lstrip("/"), safe="/")
82
+ return f"{self._base}/{bucket}/{safe_key}"
83
+
84
+ def download(self, bucket: str, key: str) -> bytes:
85
+ headers: Dict[str, str] = {}
86
+ if self._upload_key:
87
+ headers["x-upload-key"] = self._upload_key
88
+ resp = self._session.get(
89
+ self._url(bucket, key),
90
+ headers=headers,
91
+ proxies=self._proxies,
92
+ timeout=self._timeout,
93
+ )
94
+ resp.raise_for_status()
95
+ return resp.content
96
+
97
+ def upload(
98
+ self,
99
+ bucket: str,
100
+ key: str,
101
+ body: bytes,
102
+ content_type: str = DEFAULT_CONTENT_TYPE,
103
+ ) -> None:
104
+ headers = {
105
+ "x-amz-content-sha256": hashlib.sha256(body).hexdigest(),
106
+ "Content-Type": content_type,
107
+ }
108
+ if self._upload_key:
109
+ headers["x-upload-key"] = self._upload_key
110
+ resp = self._session.put(
111
+ self._url(bucket, key),
112
+ headers=headers,
113
+ data=body,
114
+ proxies=self._proxies,
115
+ timeout=self._timeout,
116
+ )
117
+ resp.raise_for_status()
118
+
119
+ def upload_files(
120
+ self,
121
+ bucket: str,
122
+ key_prefix: str,
123
+ files: Mapping[str, bytes],
124
+ *,
125
+ content_type_for: Optional[Mapping[str, str]] = None,
126
+ ) -> None:
127
+ """把 ``{相对路径: bytes}`` 批量上传到 ``{bucket}/{key_prefix}/{相对路径}``。
128
+
129
+ Args:
130
+ bucket: 目标 bucket。
131
+ key_prefix: key 前缀(会与相对路径以 ``/`` 拼接;空前缀直接用相对路径)。
132
+ files: 相对路径 → bytes,通常是 ``TransformResult.to_files()`` 的返回。
133
+ content_type_for: 可选,按相对路径覆盖 Content-Type;缺省用
134
+ ``application/octet-stream``。
135
+ """
136
+ prefix = key_prefix.strip("/")
137
+ for rel_path, body in files.items():
138
+ key = f"{prefix}/{rel_path}" if prefix else rel_path
139
+ ctype = DEFAULT_CONTENT_TYPE
140
+ if content_type_for and rel_path in content_type_for:
141
+ ctype = content_type_for[rel_path]
142
+ self.upload(bucket, key, body, ctype)
143
+
144
+ def close(self) -> None:
145
+ self._session.close()
146
+
147
+ def __enter__(self) -> "CdnUploader":
148
+ return self
149
+
150
+ def __exit__(self, *exc) -> None:
151
+ self.close()
@@ -0,0 +1,16 @@
1
+ """pdf_transformer:各模型专属的 raw 结果 → 统一 blocks / pages 转换器集合。
2
+
3
+ 每个模型一个子包,产出同一套与模型无关的 blocks / pages 结构,交给
4
+ :mod:`orbitkit.pdf_validator` 做减法过滤、:mod:`orbitkit.cdn_uploader` 上传:
5
+
6
+ - :mod:`orbitkit.pdf_transformer.mineru` —— MinerU Router ZIP 转换器(现有)。
7
+ - 后续:``datalab`` 等其它模型。
8
+
9
+ 用法::
10
+
11
+ from orbitkit.pdf_transformer.mineru import transform_zip, transform_chunk_zips
12
+ """
13
+
14
+ from . import mineru
15
+
16
+ __all__ = ["mineru"]
@@ -0,0 +1,41 @@
1
+ """MinerU 结果转换:raw ZIP → 结构化 blocks / pages(已旋转纠正 + 已 Y 翻转)+ 透传文件。
2
+
3
+ 本子包只负责 **模型专属的解析 + 几何归一**,产出与模型无关的 blocks / pages
4
+ 结构(:class:`TransformResult`)。**内容过滤**(减法)交给
5
+ :mod:`orbitkit.pdf_validator`;**上传**交给 :mod:`orbitkit.cdn_uploader`。
6
+
7
+ MinerU 是 :mod:`orbitkit.pdf_transformer` 下的其中一个模型实现(后续会有
8
+ ``datalab`` 等),各模型都产出同一套 blocks / pages 结构以复用 pdf_validator。
9
+
10
+ 典型用法::
11
+
12
+ from orbitkit.pdf_transformer.mineru import transform_zip
13
+ from orbitkit.pdf_validator import apply_block_filters, resolve_filters, parse_filter_names
14
+
15
+ result = transform_zip(zip_bytes, page_geometry=geom, bbox_rotate_correction=True)
16
+ result.blocks, result.pages = apply_block_filters(
17
+ result.blocks, result.pages,
18
+ resolve_filters(parse_filter_names("drop_non_text,drop_vlm_artifacts")),
19
+ )
20
+ files = result.to_files() # Dict[相对路径, bytes],可直接上传
21
+ """
22
+
23
+ from .bbox_rotation import (
24
+ PageGeometry,
25
+ bbox_inverse_rotate,
26
+ correct_middle_bbox_rotation,
27
+ )
28
+ from .result_transformer import (
29
+ TransformResult,
30
+ transform_chunk_zips,
31
+ transform_zip,
32
+ )
33
+
34
+ __all__ = [
35
+ "TransformResult",
36
+ "transform_zip",
37
+ "transform_chunk_zips",
38
+ "PageGeometry",
39
+ "bbox_inverse_rotate",
40
+ "correct_middle_bbox_rotation",
41
+ ]
@@ -0,0 +1,218 @@
1
+ """bbox 旋转纠正:把 mineru middle.json 里 display 帧的 bbox 转回 MediaBox 帧。
2
+
3
+ # 现象(mineru 上游 bug)
4
+
5
+ 1. mineru 用 pdfium 渲染时调用 ``page.render()``,pdfium **会自动应用
6
+ /Rotate**,所以渲染出来的图像在 "/Rotate 后的视觉帧"(display frame)。
7
+ VLM 看到的、输出 bbox 的图像就是这个 display frame。
8
+ 2. mineru 把 bbox 写进 middle.json 时,**没有**把图像旋转过的事实补回去
9
+ → middle.json 里的 bbox 留在 display frame。
10
+ 3. 但 middle.json 的 ``page_size`` 字段是用 ``page.get_size()`` 拿的
11
+ **未旋转 MediaBox** 尺寸,**没有** W↔H 互换。
12
+
13
+ ⇒ 同一个 middle.json 里 ``page_size`` 与 bbox 坐标系不一致:
14
+ - page_size = (mediabox_W, mediabox_H)
15
+ - bbox ∈ display frame = (mediabox_H, mediabox_W) for /Rotate∈{90,270}
16
+
17
+ 下游若拿这份 bbox 去叠在原 PDF 渲染图(mediabox 帧)上 → 偏移 / 越界。
18
+
19
+ # 修复
20
+
21
+ 把 bbox 反向旋转回 MediaBox 帧。
22
+
23
+ ## 检测条件
24
+
25
+ * /Rotate ∈ {90, 270} → 进入纠正分支。
26
+ * **不**依赖 "page_size 维度互换" —— mineru 永远报未旋转 MediaBox,
27
+ 这个条件**永远不成立**,不能作为信号。
28
+ * 仅做防御性 sanity:若 page_size 与 MediaBox 不一致(mineru 行为变了
29
+ 或上游修了 bug),warn 一下、继续按旋转纠正走,需要人工看日志。
30
+
31
+ 不处理 180°:mediabox 维度不变 → 检测不到;即使强制处理,也需要内容
32
+ 方向探测(OCR)才知道要不要转。punt。
33
+
34
+ ## 旋转公式
35
+
36
+ image 坐标系,top-left 原点 Y 向下:
37
+ - /Rotate=90 反向:``[x0,y0,x1,y1]`` → ``[y0, H_m - x1, y1, H_m - x0]``
38
+ - /Rotate=270 反向:``[x0,y0,x1,y1]`` → ``[W_m - y1, x0, W_m - y0, x1]``
39
+
40
+ 推导见 :func:`bbox_inverse_rotate` 函数内注释。
41
+ """
42
+
43
+ from __future__ import annotations
44
+
45
+ import logging
46
+ from typing import Any, Callable, List, Optional, Tuple
47
+
48
+ log = logging.getLogger(__name__)
49
+
50
+ # 每页几何信息:(mediabox_w, mediabox_h, /Rotate value 0/90/180/270)
51
+ PageGeometry = Tuple[float, float, int]
52
+
53
+ # 检测维度交换时的容差(像素)。mineru 的 page_size 偶尔有 ±1 浮点抖动。
54
+ DIM_SWAP_EPS = 2.0
55
+
56
+
57
+ def bbox_inverse_rotate(
58
+ bbox: List[float],
59
+ rotate: int,
60
+ media_w: float,
61
+ media_h: float,
62
+ ) -> List[float]:
63
+ """把 mineru 输出的 bbox(display 帧)反向旋转回 mediabox 帧。
64
+
65
+ bbox 输入约定:top-left 原点、Y 向下,且 ``x0 <= x1, y0 <= y1``。
66
+ 输出同约定,在 (media_w, media_h) 帧里。
67
+
68
+ Args:
69
+ bbox: 4 元素 ``[x0, y0, x1, y1]``(display frame)
70
+ rotate: PDF /Rotate 字段值,仅 90/270 会真正旋转;其它值原样返回
71
+ media_w: MediaBox 宽
72
+ media_h: MediaBox 高
73
+
74
+ Returns:
75
+ 转回 mediabox 帧的 4 元素 bbox。
76
+ """
77
+ x0, y0, x1, y1 = bbox
78
+ # 防御性:调用方应保证 x0<=x1 / y0<=y1,但 middle.json 里偶有反向
79
+ if x0 > x1:
80
+ x0, x1 = x1, x0
81
+ if y0 > y1:
82
+ y0, y1 = y1, y0
83
+
84
+ if rotate == 90:
85
+ # 推导:mediabox→display 是 +90° CW,即 (x_m, y_m) → (H_m - y_m, x_m)。
86
+ # 反向是 -90° CW = +270° CW,即 display(x_d, y_d) → (y_d, H_m - x_d)。
87
+ # bbox 4 角全部映射后取 min/max:
88
+ return [y0, media_h - x1, y1, media_h - x0]
89
+ if rotate == 270:
90
+ # 推导:mediabox→display 是 +270° CW,即 (x_m, y_m) → (y_m, W_m - x_m)。
91
+ # 反向是 +90° CW,即 display(x_d, y_d) → (W_m - y_d, x_d)。
92
+ return [media_w - y1, x0, media_w - y0, x1]
93
+ # 其它情况(0 或 180 或非法值)不应到达此函数;保持不变以防错改
94
+ return [x0, y0, x1, y1]
95
+
96
+
97
+ def walk_and_fix_bboxes(
98
+ node: Any,
99
+ fix: Callable[[List[float]], List[float]],
100
+ counter: List[int],
101
+ ) -> None:
102
+ """递归遍历 page 子树(para_blocks/discarded_blocks/blocks/lines/spans),
103
+ 对所有 ``bbox`` 字段应用 ``fix``。
104
+
105
+ ``counter[0]`` 累加修改次数,便于日志统计。仅修改 ``list[float|int]``
106
+ 形式且长度 ≥ 4 的 bbox;其它形态保持不变。
107
+ """
108
+ if isinstance(node, dict):
109
+ bbox = node.get("bbox")
110
+ if (
111
+ isinstance(bbox, list)
112
+ and len(bbox) >= 4
113
+ and all(isinstance(v, (int, float)) for v in bbox[:4])
114
+ ):
115
+ new_bbox = fix(list(map(float, bbox[:4])))
116
+ # 保留 bbox 列表后续元素(如有)
117
+ node["bbox"] = new_bbox + list(bbox[4:])
118
+ counter[0] += 1
119
+ for child_key in ("para_blocks", "discarded_blocks", "blocks", "lines", "spans"):
120
+ child = node.get(child_key)
121
+ if isinstance(child, list):
122
+ for item in child:
123
+ walk_and_fix_bboxes(item, fix, counter)
124
+ elif isinstance(node, list):
125
+ for item in node:
126
+ walk_and_fix_bboxes(item, fix, counter)
127
+
128
+
129
+ def correct_middle_bbox_rotation(
130
+ middle: dict,
131
+ page_geometry: List[PageGeometry],
132
+ *,
133
+ attach_id: Optional[int] = None,
134
+ ) -> None:
135
+ """对 middle.json 的每页应用 bbox 旋转纠正。**就地修改** ``middle``。
136
+
137
+ 检测:``/Rotate ∈ {90, 270}``。其它情况(0° / 180° / 非法值 / geometry
138
+ 越界 / page_size 缺失)→ 跳过。
139
+
140
+ Sanity 校验:mineru 当前行为是 ``page_size`` = 未旋转 MediaBox。若实际
141
+ page_size 与 MediaBox 维度不匹配(说明 mineru 行为变了、可能已经在上游
142
+ 修了 bug),打 warn 但仍按旋转纠正走 —— 防止"我们以为修了,结果 mineru
143
+ 自己又把 bbox 旋转回去导致双重旋转",需要人工看日志。
144
+
145
+ Args:
146
+ middle: middle.json 顶层 dict(应有 ``pdf_info`` 列表)
147
+ page_geometry: 每页 ``(mediabox_w, mediabox_h, /Rotate)``,索引按
148
+ 页面 ``page_idx`` 对齐
149
+ attach_id: 仅用于日志关联,不影响行为
150
+ """
151
+ pdf_info = middle.get("pdf_info") or []
152
+ for page in pdf_info:
153
+ if not isinstance(page, dict):
154
+ continue
155
+ page_idx = page.get("page_idx", -1)
156
+ if not isinstance(page_idx, int) or page_idx < 0 or page_idx >= len(page_geometry):
157
+ continue
158
+ media_w, media_h, rotate = page_geometry[page_idx]
159
+
160
+ if rotate not in (90, 270):
161
+ # 0° / 180° / 非法 → 不动
162
+ continue
163
+
164
+ mineru_size = page.get("page_size") or [0, 0]
165
+ if len(mineru_size) < 2:
166
+ continue
167
+ try:
168
+ mineru_w = float(mineru_size[0])
169
+ mineru_h = float(mineru_size[1])
170
+ except (TypeError, ValueError):
171
+ continue
172
+
173
+ # Sanity: mineru 期望行为 = page_size 与 MediaBox 维度一致(未旋转)
174
+ size_matches_mediabox = (
175
+ abs(mineru_w - media_w) < DIM_SWAP_EPS
176
+ and abs(mineru_h - media_h) < DIM_SWAP_EPS
177
+ )
178
+ size_is_swapped = (
179
+ abs(mineru_w - media_h) < DIM_SWAP_EPS
180
+ and abs(mineru_h - media_w) < DIM_SWAP_EPS
181
+ )
182
+ if not size_matches_mediabox:
183
+ if size_is_swapped:
184
+ # mineru 行为可能变了:page_size 用旋转后帧。这种情况下 bbox
185
+ # 是已经在 mediabox 帧(mineru 自己修了 bug),还是在 display
186
+ # 帧(mineru 改了 page_size 但忘了改 bbox),从 JSON 看不出。
187
+ # 保守仍按旋转纠正走,加 warn 让人工观察。
188
+ log.warning(
189
+ "bbox-correct: page_size W↔H swap vs MediaBox detected "
190
+ "(mineru=%.0fx%.0f mediabox=%.0fx%.0f /Rotate=%d). mineru "
191
+ "behavior may have changed — verify staging output before "
192
+ "trusting this correction (attach=%s page_idx=%d)",
193
+ mineru_w, mineru_h, media_w, media_h, rotate, attach_id, page_idx,
194
+ )
195
+ else:
196
+ # 完全不一致(既不匹配 MediaBox 也不是 W↔H swap)→ 异常,
197
+ # warn 但仍按旋转纠正走
198
+ log.warning(
199
+ "bbox-correct: page_size %.0fx%.0f mismatches both "
200
+ "MediaBox %.0fx%.0f and its swap; proceeding with rotation "
201
+ "anyway (attach=%s page_idx=%d /Rotate=%d)",
202
+ mineru_w, mineru_h, media_w, media_h, attach_id, page_idx, rotate,
203
+ )
204
+
205
+ def _fix(bbox: List[float]) -> List[float]:
206
+ return bbox_inverse_rotate(bbox, rotate, media_w, media_h)
207
+
208
+ counter = [0]
209
+ walk_and_fix_bboxes(page, _fix, counter)
210
+ # 把 page_size 改为 mediabox 帧;下游 _parse_middle 用 page_size[1]
211
+ # 做 Y 翻转时才能用对的 H_m。即使 mineru 已经写了 mediabox 也无副作用。
212
+ page["page_size"] = [media_w, media_h]
213
+ log.info(
214
+ "bbox-correct: rotated %d bbox(es) by inverse of /Rotate=%d "
215
+ "(attach=%s page_idx=%d, mineru=%.0fx%.0f → mediabox=%.0fx%.0f)",
216
+ counter[0], rotate, attach_id, page_idx,
217
+ mineru_w, mineru_h, media_w, media_h,
218
+ )