ocrroute 0.8.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.
Files changed (231) hide show
  1. AioOCR/__init__.py +56 -0
  2. AioOCR/engines/__init__.py +4 -0
  3. AioOCR/engines/api/__init__.py +4 -0
  4. AioOCR/engines/api/aimlapiocr.py +195 -0
  5. AioOCR/engines/api/apininjasocr.py +182 -0
  6. AioOCR/engines/api/baiduocrapi.py +275 -0
  7. AioOCR/engines/api/chatgptocr.py +384 -0
  8. AioOCR/engines/api/claudeocr.py +277 -0
  9. AioOCR/engines/api/easyocrorg.py +168 -0
  10. AioOCR/engines/api/geminiocr.py +289 -0
  11. AioOCR/engines/api/googleocr.py +224 -0
  12. AioOCR/engines/api/grokocr.py +413 -0
  13. AioOCR/engines/api/groqocr.py +399 -0
  14. AioOCR/engines/api/mistralocr.py +290 -0
  15. AioOCR/engines/api/nanonetsapi.py +304 -0
  16. AioOCR/engines/api/nvidianenotronocr.py +432 -0
  17. AioOCR/engines/api/nvidiapaddleocr.py +298 -0
  18. AioOCR/engines/api/ocrspace.py +273 -0
  19. AioOCR/engines/api/omniroute.py +387 -0
  20. AioOCR/engines/api/openrouteocr.py +305 -0
  21. AioOCR/engines/api/perplexityocr.py +363 -0
  22. AioOCR/engines/api/qwencloudocr.py +1322 -0
  23. AioOCR/engines/api/rapidapiapi4aiocr.py +189 -0
  24. AioOCR/engines/api/rapidapiocrextracttext.py +352 -0
  25. AioOCR/engines/api/scandocflow.py +263 -0
  26. AioOCR/engines/api/siliconflowocr.py +587 -0
  27. AioOCR/engines/languages.py +258 -0
  28. AioOCR/engines/local/__init__.py +4 -0
  29. AioOCR/engines/local/calamariocr.py +128 -0
  30. AioOCR/engines/local/chandraocr.py +286 -0
  31. AioOCR/engines/local/deepseekocr.py +422 -0
  32. AioOCR/engines/local/dotsocr.py +385 -0
  33. AioOCR/engines/local/easy.py +98 -0
  34. AioOCR/engines/local/glmocrhf.py +194 -0
  35. AioOCR/engines/local/gotocr.py +337 -0
  36. AioOCR/engines/local/hunyuanocrhf.py +538 -0
  37. AioOCR/engines/local/infinityparser.py +236 -0
  38. AioOCR/engines/local/kerasocr.py +69 -0
  39. AioOCR/engines/local/mmocrlib.py +1198 -0
  40. AioOCR/engines/local/monkeyocrhf.py +236 -0
  41. AioOCR/engines/local/monkeyocrpro.py +131 -0
  42. AioOCR/engines/local/nanonetsocr2.py +318 -0
  43. AioOCR/engines/local/nougatlib.py +353 -0
  44. AioOCR/engines/local/nougatocr.py +208 -0
  45. AioOCR/engines/local/olmocrlib.py +316 -0
  46. AioOCR/engines/local/openocr.py +1157 -0
  47. AioOCR/engines/local/paddleocrlib.py +202 -0
  48. AioOCR/engines/local/pytorchocr.py +401 -0
  49. AioOCR/engines/local/qianfanocr.py +271 -0
  50. AioOCR/engines/local/qwen2vl2bocr.py +235 -0
  51. AioOCR/engines/local/qwenvlocrlib.py +1244 -0
  52. AioOCR/engines/local/rapidocrlib.py +284 -0
  53. AioOCR/engines/local/suryaocr.py +202 -0
  54. AioOCR/engines/local/tesseract.py +330 -0
  55. AioOCR/engines/local/trocr.py +391 -0
  56. AioOCR/engines/local/trocrhandwritten.py +102 -0
  57. AioOCR/engines/local/unlimitedocr.py +406 -0
  58. AioOCR/engines/ocrplugin.py +845 -0
  59. AioOCR/ocrbase.py +52 -0
  60. AioOCR/requirements.txt +13 -0
  61. ocrroute/__init__.py +7 -0
  62. ocrroute/__main__.py +10 -0
  63. ocrroute/api/__init__.py +6 -0
  64. ocrroute/api/app.py +213 -0
  65. ocrroute/api/deps.py +20 -0
  66. ocrroute/api/routers/__init__.py +6 -0
  67. ocrroute/api/routers/admin.py +863 -0
  68. ocrroute/api/routers/endpoints.py +188 -0
  69. ocrroute/api/routers/jobs.py +158 -0
  70. ocrroute/api/routers/ocr.py +159 -0
  71. ocrroute/api/routers/runs.py +213 -0
  72. ocrroute/api/routers/sync.py +394 -0
  73. ocrroute/api/routers/system.py +52 -0
  74. ocrroute/api/routers/tools.py +36 -0
  75. ocrroute/api/routers/users.py +147 -0
  76. ocrroute/api/schemas/__init__.py +6 -0
  77. ocrroute/api/schemas/admin.py +150 -0
  78. ocrroute/api/schemas/ocr.py +62 -0
  79. ocrroute/api/security.py +69 -0
  80. ocrroute/assets/fonts/DejaVuSans.ttf +0 -0
  81. ocrroute/assets/fonts/LICENSE-DejaVu.txt +68 -0
  82. ocrroute/catalog/__init__.py +9 -0
  83. ocrroute/catalog/engines.toml +392 -0
  84. ocrroute/catalog/registry.py +812 -0
  85. ocrroute/cli/__init__.py +6 -0
  86. ocrroute/cli/commands/__init__.py +6 -0
  87. ocrroute/cli/main.py +1120 -0
  88. ocrroute/compat/__init__.py +9 -0
  89. ocrroute/compat/ocrbase.py +78 -0
  90. ocrroute/compat/py23.py +89 -0
  91. ocrroute/config.py +94 -0
  92. ocrroute/crypto.py +82 -0
  93. ocrroute/db/__init__.py +9 -0
  94. ocrroute/db/base.py +17 -0
  95. ocrroute/db/models.py +354 -0
  96. ocrroute/db/repo/__init__.py +10 -0
  97. ocrroute/db/repo/engines.py +69 -0
  98. ocrroute/db/repo/stats.py +152 -0
  99. ocrroute/db/repo/usage.py +98 -0
  100. ocrroute/db/session.py +93 -0
  101. ocrroute/desktop/__init__.py +4 -0
  102. ocrroute/desktop/__main__.py +9 -0
  103. ocrroute/desktop/app.py +337 -0
  104. ocrroute/desktop/client.py +245 -0
  105. ocrroute/desktop/i18n.py +84 -0
  106. ocrroute/desktop/icons.py +85 -0
  107. ocrroute/desktop/pages/__init__.py +4 -0
  108. ocrroute/desktop/pages/base.py +102 -0
  109. ocrroute/desktop/pages/dashboard.py +149 -0
  110. ocrroute/desktop/pages/endpoints.py +180 -0
  111. ocrroute/desktop/pages/misc.py +191 -0
  112. ocrroute/desktop/pages/scan.py +603 -0
  113. ocrroute/desktop/pages/tables.py +661 -0
  114. ocrroute/desktop/server.py +110 -0
  115. ocrroute/desktop/state.py +140 -0
  116. ocrroute/desktop/styles/dark.qss +66 -0
  117. ocrroute/desktop/styles/light.qss +66 -0
  118. ocrroute/desktop/widgets/__init__.py +6 -0
  119. ocrroute/desktop/widgets/image_viewer.py +177 -0
  120. ocrroute/desktop/widgets/region_capture.py +65 -0
  121. ocrroute/desktop/workers/__init__.py +83 -0
  122. ocrroute/edition.py +38 -0
  123. ocrroute/enginelib.py +312 -0
  124. ocrroute/enginelib_probe.py +80 -0
  125. ocrroute/errors.py +237 -0
  126. ocrroute/i18n/__init__.py +134 -0
  127. ocrroute/i18n/ar.json +574 -0
  128. ocrroute/i18n/de.json +574 -0
  129. ocrroute/i18n/en.json +574 -0
  130. ocrroute/i18n/es.json +574 -0
  131. ocrroute/i18n/fr.json +574 -0
  132. ocrroute/i18n/it.json +574 -0
  133. ocrroute/i18n/pt.json +574 -0
  134. ocrroute/i18n/ru.json +574 -0
  135. ocrroute/i18n/zh.json +574 -0
  136. ocrroute/ids.py +22 -0
  137. ocrroute/logsetup.py +53 -0
  138. ocrroute/opencv_alias.py +86 -0
  139. ocrroute/paddleenv.py +171 -0
  140. ocrroute/panel/__init__.py +6 -0
  141. ocrroute/panel/app.py +586 -0
  142. ocrroute/panel/auth.py +105 -0
  143. ocrroute/panel/static/apple-touch-icon.png +0 -0
  144. ocrroute/panel/static/favicon-32.png +0 -0
  145. ocrroute/panel/static/favicon-dark.svg +1 -0
  146. ocrroute/panel/static/favicon.ico +0 -0
  147. ocrroute/panel/static/favicon.svg +1 -0
  148. ocrroute/panel/static/icon-192.png +0 -0
  149. ocrroute/panel/static/icon-512.png +0 -0
  150. ocrroute/panel/static/panel.css +279 -0
  151. ocrroute/panel/static/panel.js +318 -0
  152. ocrroute/panel/static/site.webmanifest +1 -0
  153. ocrroute/panel/static/vendor/alpine.min.js +5 -0
  154. ocrroute/panel/static/vendor/bootstrap-icons.min.css +5 -0
  155. ocrroute/panel/static/vendor/bootstrap.bundle.min.js +7 -0
  156. ocrroute/panel/static/vendor/bootstrap.min.css +6 -0
  157. ocrroute/panel/static/vendor/chart.umd.js +14 -0
  158. ocrroute/panel/static/vendor/fonts/bootstrap-icons.woff +0 -0
  159. ocrroute/panel/static/vendor/fonts/bootstrap-icons.woff2 +0 -0
  160. ocrroute/panel/static/vendor/htmx.min.js +1 -0
  161. ocrroute/panel/templates/base.html +55 -0
  162. ocrroute/panel/templates/batch.html +32 -0
  163. ocrroute/panel/templates/cluster.html +271 -0
  164. ocrroute/panel/templates/doctor.html +13 -0
  165. ocrroute/panel/templates/empty.html +2 -0
  166. ocrroute/panel/templates/endpoints.html +109 -0
  167. ocrroute/panel/templates/engines.html +43 -0
  168. ocrroute/panel/templates/keys.html +22 -0
  169. ocrroute/panel/templates/login.html +20 -0
  170. ocrroute/panel/templates/overview.html +43 -0
  171. ocrroute/panel/templates/partials/sidebar.html +19 -0
  172. ocrroute/panel/templates/playground.html +85 -0
  173. ocrroute/panel/templates/providers.html +104 -0
  174. ocrroute/panel/templates/routes.html +125 -0
  175. ocrroute/panel/templates/run_detail.html +36 -0
  176. ocrroute/panel/templates/runs.html +14 -0
  177. ocrroute/panel/templates/settings.html +39 -0
  178. ocrroute/panel/templates/setup.html +21 -0
  179. ocrroute/panel/templates/tools.html +13 -0
  180. ocrroute/panel/templates/usage.html +18 -0
  181. ocrroute/pipeline/__init__.py +6 -0
  182. ocrroute/pipeline/export/__init__.py +30 -0
  183. ocrroute/pipeline/export/alto.py +47 -0
  184. ocrroute/pipeline/export/csvxlsx.py +61 -0
  185. ocrroute/pipeline/export/docx.py +51 -0
  186. ocrroute/pipeline/export/hocr.py +41 -0
  187. ocrroute/pipeline/export/markdown.py +20 -0
  188. ocrroute/pipeline/export/plain.py +19 -0
  189. ocrroute/pipeline/export/searchable_pdf.py +100 -0
  190. ocrroute/pipeline/input.py +421 -0
  191. ocrroute/pipeline/overlay.py +34 -0
  192. ocrroute/pipeline/postprocess.py +91 -0
  193. ocrroute/pipeline/preprocess.py +316 -0
  194. ocrroute/routing/__init__.py +4 -0
  195. ocrroute/routing/breaker.py +127 -0
  196. ocrroute/routing/candidates.py +792 -0
  197. ocrroute/routing/consensus.py +73 -0
  198. ocrroute/routing/cost.py +17 -0
  199. ocrroute/routing/explain.py +190 -0
  200. ocrroute/routing/router.py +538 -0
  201. ocrroute/routing/strategies/__init__.py +215 -0
  202. ocrroute/routing/templates.py +75 -0
  203. ocrroute/runtime/__init__.py +6 -0
  204. ocrroute/runtime/cache.py +57 -0
  205. ocrroute/runtime/context.py +194 -0
  206. ocrroute/runtime/doctor.py +111 -0
  207. ocrroute/runtime/endpoints.py +448 -0
  208. ocrroute/runtime/engineinstall.py +111 -0
  209. ocrroute/runtime/executor.py +1439 -0
  210. ocrroute/runtime/jobs.py +186 -0
  211. ocrroute/runtime/lifecycle.py +152 -0
  212. ocrroute/runtime/limits.py +73 -0
  213. ocrroute/runtime/maintenance.py +200 -0
  214. ocrroute/runtime/sample.py +30 -0
  215. ocrroute/runtime/tunnelinstall.py +161 -0
  216. ocrroute/runtime/webhooks.py +31 -0
  217. ocrroute/selftest.py +110 -0
  218. ocrroute/stdio.py +148 -0
  219. ocrroute/sync.py +1461 -0
  220. ocrroute/tools/README.md +30 -0
  221. ocrroute/tools/__init__.py +13 -0
  222. ocrroute/tools/base.py +42 -0
  223. ocrroute/tools/builtin/__init__.py +4 -0
  224. ocrroute/tools/registry.py +53 -0
  225. ocrroute/version.py +7 -0
  226. ocrroute-0.8.0.dist-info/METADATA +227 -0
  227. ocrroute-0.8.0.dist-info/RECORD +231 -0
  228. ocrroute-0.8.0.dist-info/WHEEL +5 -0
  229. ocrroute-0.8.0.dist-info/entry_points.txt +2 -0
  230. ocrroute-0.8.0.dist-info/licenses/LICENSE +18 -0
  231. ocrroute-0.8.0.dist-info/top_level.txt +2 -0
AioOCR/__init__.py ADDED
@@ -0,0 +1,56 @@
1
+ # coding=utf-8
2
+ """
3
+ None
4
+ """
5
+ from os.path import dirname, join, exists
6
+ from inspect import getmembers, isclass
7
+ from importlib import import_module
8
+ from pkgutil import iter_modules
9
+
10
+ # Import the base class for type checking.
11
+ try:
12
+ from .engines.ocrplugin import OCRPlugin
13
+ except:
14
+ from engines.ocrplugin import OCRPlugin
15
+
16
+ # Dictionary to hold all discovered classes: {"ClassName": ClassObject}.
17
+ AVAILABLE_PLUGINS = {}
18
+
19
+
20
+ def _discoverOcrPlugins():
21
+ """
22
+ Dynamically loads all OCRPlugin subclasses from api/ and local/ packages.
23
+ """
24
+ for subPkg in ['api', 'local']:
25
+ packagePath = join(dirname(__file__), 'engines', subPkg)
26
+ if not exists(packagePath):
27
+ continue
28
+ # Iterate through all modules inside the subpackage directory.
29
+ for _, moduleName, isPkg in iter_modules([packagePath]):
30
+ if isPkg or moduleName.startswith("_"):
31
+ continue
32
+ fullModuleName = 'engines.{}.{}'.format(subPkg, moduleName)
33
+ try:
34
+ # Import the module relative to the root package.
35
+ try:
36
+ module = import_module('.engines.{}.{}'.format(subPkg, moduleName), package=__package__)
37
+ except:
38
+ module = import_module(fullModuleName, package=__package__)
39
+ # Inspect module members for OCRPlugin subclasses.
40
+ for name, obj in getmembers(module, isclass):
41
+ # Check if it inherits from OCRPlugin (excluding OCRPlugin itself).
42
+ if issubclass(obj, OCRPlugin) and obj is not OCRPlugin:
43
+ # Ensure the class was defined in that module (not imported into it).
44
+ if obj.__module__ == module.__name__:
45
+ AVAILABLE_PLUGINS[name] = obj
46
+ # Expose class at root level.
47
+ globals()[name] = obj
48
+ except Exception as err:
49
+ # Optional: Handle or log import errors (e.g., missing local dependencies like easyocr, tesseract).
50
+ print('Warning: Could not import {}: {}'.format(fullModuleName, err))
51
+
52
+
53
+ # Run plugin discovery
54
+ _discoverOcrPlugins()
55
+ # Export discovered plugin classes
56
+ __all__ = list(AVAILABLE_PLUGINS.keys())
@@ -0,0 +1,4 @@
1
+ # coding=utf-8
2
+ """
3
+ None
4
+ """
@@ -0,0 +1,4 @@
1
+ # coding=utf-8
2
+ """
3
+ OCR API Modules.
4
+ """
@@ -0,0 +1,195 @@
1
+ # coding=utf-8
2
+ """
3
+ AI/ML API OCR plugin (api.aimlapi.com/v1/ocr).
4
+ AIMLAPI is a model aggregator: one API key gives access to hundreds of
5
+ models, including Mistral's OCR line through a dedicated OCR route::
6
+ POST https://api.aimlapi.com/v1/ocr
7
+ Authorization: Bearer <key>
8
+ {"model": "mistral-ocr-latest", "document": {"type": "image_url", "image_url": "..."}}
9
+ Setup: create a key at https://aimlapi.com/app/keys (new accounts get
10
+ a small free allowance; paid plans unlock volume). Limits per the API
11
+ schema: 50 MB per file, up to 1,000 pages.
12
+ Available models: 'mistral-ocr-latest' (default), 'mistral-ocr-2512',
13
+ 'mistral-ocr-3'. The reply carries one markdown string per page plus
14
+ page dimensions; the hosted generation returns NO text coordinates
15
+ (bounding boxes exist only for extracted figures/images), so reading
16
+ order is preserved with synthesized row boxes in the same unified
17
+ structure as every other plugin -- if you need true text geometry
18
+ from Mistral, use the direct ``mistralocr.py`` plugin whose OCR 4
19
+ ``include_blocks`` feature provides it.
20
+ Local files, PIL images and raw bytes are sent inline as base64 data
21
+ URIs; public URLs pass through untouched.
22
+ """
23
+ from base64 import b64encode
24
+ from os.path import dirname
25
+ from requests import post
26
+ from os import environ
27
+ from sys import path
28
+ from re import sub
29
+
30
+ if dirname(__file__) not in path:
31
+ path.append(dirname(__file__))
32
+ if dirname(dirname(__file__)) not in path:
33
+ path.append(dirname(dirname(__file__)))
34
+
35
+ try:
36
+ from .ocrplugin import OCRPlugin, OCRError
37
+ except:
38
+ from engines.ocrplugin import OCRPlugin, OCRError
39
+
40
+ _ENDPOINT = 'https://api.aimlapi.com/v1/ocr'
41
+ _MODELS = ('mistral-ocr-latest', 'mistral-ocr-2512', 'mistral-ocr-3')
42
+
43
+
44
+ class AimlApiOcr(OCRPlugin):
45
+ """
46
+ AimlApiOcr class.
47
+ """
48
+ DEFAULT_MODEL = 'mistral-ocr-latest'
49
+ MODELS = _MODELS
50
+
51
+ def __init__(self, *args, **kwargs):
52
+ """
53
+ :param api: AIMLAPI key (or ``api=``, or the AIMLAPI_API_KEY environment variable).
54
+ :param model: OCR model id (default 'mistral-ocr-latest'; see _MODELS).
55
+ :param pages: optional list of 0-based page indices, or a string like '0,2-4' (PDFs).
56
+ :param docType: force 'document' or 'image' routing when the URL auto-detection guesses wrong.
57
+ :param includeImages: request extracted images as base64
58
+ (default False; ignored by the unified structure anyway).
59
+ :param imageLimit: max images to extract (optional).
60
+ :param imageMinSize: min side of images to extract (optional).
61
+ :param image: image/PDF source (path, URL, PIL, bytes...).
62
+ :param kwargs: other settings (endpoint, timeout, retries, proxy...).
63
+ """
64
+ api = kwargs.pop('api', environ.get('AIMLAPI_API_KEY', ''))
65
+ model = kwargs.pop('model', 'mistral-ocr-latest')
66
+ if model not in _MODELS:
67
+ raise OCRError('Unknown model {!r}; choose from {}'.format(model, ', '.join(_MODELS)))
68
+ self.__m_pages = kwargs.pop('pages', None)
69
+ self.__m_docType = kwargs.pop('docType', None)
70
+ self.__m_includeImages = bool(kwargs.pop('includeImages', False))
71
+ self.__m_imageLimit = kwargs.pop('imageLimit', None)
72
+ self.__m_imageMinSize = kwargs.pop('imageMinSize', None)
73
+ kwargs.setdefault('timeout', 120)
74
+ kwargs.setdefault('endpoint', _ENDPOINT)
75
+ super(AimlApiOcr, self).__init__(*args, **kwargs)
76
+ self.setOnline(True)
77
+ self.setApi(api)
78
+ self.setModel(model)
79
+
80
+ # ------------------------------------------------------------------ #
81
+ # Document building. #
82
+ # ------------------------------------------------------------------ #
83
+ def _document(self):
84
+ kind = self.imageKind()
85
+ source = self.getImage()
86
+ if kind == 'url':
87
+ is_pdf = source.lower().split('?')[0].endswith('.pdf')
88
+ if self.__m_docType:
89
+ is_pdf = self.__m_docType == 'document'
90
+ if is_pdf:
91
+ return {'type': 'document_url', 'document_url': source}
92
+ return {'type': 'image_url', 'image_url': source}
93
+ if kind == 'array':
94
+ from io import BytesIO
95
+ from PIL import Image
96
+ buffer = BytesIO()
97
+ Image.fromarray(source).save(buffer, format='PNG')
98
+ data = buffer.getvalue()
99
+ elif kind in ('path', 'pil', 'bytes', 'buffer'):
100
+ data = self.imageBytes()
101
+ else:
102
+ raise OCRError(
103
+ "Image source '{}' is not an existing file, URL, or "
104
+ "supported type. Check the path (the current working "
105
+ "directory matters for relative paths).".format(source))
106
+ if len(data) > 50 * 1024 * 1024:
107
+ raise OCRError("File exceeds AIMLAPI's 50 MB limit.")
108
+ encoded = b64encode(data).decode('ascii')
109
+ if data[:5] == b'%PDF-':
110
+ return {'type': 'document_url', 'document_url': 'data:application/pdf;base64,' + encoded}
111
+ mime = ('image/jpeg' if data[:3] == b'\xff\xd8\xff' else 'image/png')
112
+ return {'type': 'image_url', 'image_url': 'data:{};base64,{}'.format(mime, encoded)}
113
+
114
+ # ------------------------------------------------------------------ #
115
+ # Result mapping #
116
+ # ------------------------------------------------------------------ #
117
+ @staticmethod
118
+ def _field(obj, *names):
119
+ for name in names:
120
+ if isinstance(obj, dict) and name in obj:
121
+ return obj[name]
122
+ value = getattr(obj, name, None)
123
+ if value is not None:
124
+ return value
125
+ return None
126
+
127
+ @classmethod
128
+ def _markdownToRows(cls, markdown, start_row=0):
129
+ words = []
130
+ row = start_row
131
+ for line in (markdown or '').splitlines():
132
+ line = sub(r'^[#>\s]+', '', line)
133
+ line = sub(r'!\[[^\]]*\]\([^)]*\)', '', line)
134
+ line = sub(r'\|', ' ', line).strip()
135
+ if not line or set(line) <= set('-: '):
136
+ continue
137
+ words.append(cls.makeWord(line, 0.0, float(row * 10), 1.0, 8.0))
138
+ row += 1
139
+ return words
140
+
141
+ # ------------------------------------------------------------------ #
142
+ # OCR #
143
+ # ------------------------------------------------------------------ #
144
+ def _run(self, image, *args, **kwargs):
145
+ """
146
+ :return: list of word dicts for the base class to assemble.
147
+ """
148
+ if not self.getApi():
149
+ raise OCRError(
150
+ 'No AIMLAPI key. Create one at '
151
+ 'https://aimlapi.com/app/keys and pass api=... or set the AIMLAPI_API_KEY environment variable.')
152
+ body = {'model': self.getModel(), 'document': self._document()}
153
+ if self.__m_pages is not None:
154
+ body['pages'] = (list(self.__m_pages) if isinstance(self.__m_pages, (list, tuple)) else self.__m_pages)
155
+ if self.__m_includeImages:
156
+ body['include_image_base64'] = True
157
+ if self.__m_imageLimit is not None:
158
+ body['image_limit'] = int(self.__m_imageLimit)
159
+ if self.__m_imageMinSize is not None:
160
+ body['image_min_size'] = int(self.__m_imageMinSize)
161
+ request_kwargs = {
162
+ 'json': body,
163
+ 'headers': {
164
+ 'Authorization': 'Bearer {}'.format(self.getApi()),
165
+ 'Content-Type': 'application/json',
166
+ },
167
+ 'timeout': self.getTimeout(),
168
+ }
169
+ if self.getProxy():
170
+ request_kwargs['proxies'] = self.getProxy()
171
+ reply = post(self.getEndpoint(), **request_kwargs)
172
+ if reply.status_code in (401, 403):
173
+ raise OCRError(
174
+ 'AIMLAPI rejected the key (HTTP {}): check it at '
175
+ 'https://aimlapi.com/app/keys. API said: {}'.format(reply.status_code, (reply.text or '')[:200]))
176
+ if reply.status_code == 402:
177
+ raise OCRError('AIMLAPI says the account is out of credits: top up at https://aimlapi.com. API said: ' + (
178
+ reply.text or '')[:200])
179
+ if reply.status_code == 429:
180
+ raise OCRError('AIMLAPI rate limit hit: ' + (reply.text or '')[:200])
181
+ if not reply.ok:
182
+ raise OCRError('HTTP {} from AIMLAPI: {}'.format(reply.status_code, (reply.text or '')[:300].strip()))
183
+ payload = reply.json()
184
+ error = payload.get('error') if isinstance(payload, dict) else None
185
+ if error:
186
+ raise OCRError('AIMLAPI error: {}'.format(str(error)[:300]))
187
+ pages = self._field(payload, 'pages') or []
188
+ pages = sorted(pages, key=lambda p: self._field(p, 'index') or 0)
189
+ words = []
190
+ for page in pages:
191
+ markdown = self._field(page, 'markdown') or ''
192
+ words.extend(self._markdownToRows(markdown, start_row=len(words)))
193
+ if not words:
194
+ raise OCRError('AIMLAPI OCR returned no text for this document.')
195
+ return words
@@ -0,0 +1,182 @@
1
+ # coding=utf-8
2
+ """
3
+ API Ninjas Image-to-Text OCR plugin
4
+ (https://api-ninjas.com/api/imagetotext).
5
+ API Ninjas is an API marketplace; its Image to Text endpoint does OCR
6
+ with word-level bounding boxes::
7
+ POST https://api.api-ninjas.com/v1/imagetotext
8
+ X-Api-Key: <key>
9
+ multipart: image=<JPEG or PNG file>
10
+ Setup: sign up free at https://api-ninjas.com to get an API key
11
+ instantly, then pass it as ``api=`` or set the API_NINJAS_API_KEY
12
+ environment variable.
13
+ Know the limits going in: the endpoint accepts ONLY JPEG/PNG (this
14
+ plugin auto-converts other raster formats and rejects PDFs), free
15
+ accounts can upload images up to 200 KB each (5 MB on premium), and
16
+ commercial use requires a premium subscription.
17
+ The reply is a JSON array of detections::
18
+ [{"text": "API", "bounding_box": {"x1": 60, "y1": 72, "x2": 163, "y2": 118}}, ...]
19
+ -- word-level PIXEL coordinates (OCR.Space granularity), mapped
20
+ straight into the exact unified structure shared by every plugin.
21
+ """
22
+ from os.path import dirname
23
+ from requests import post
24
+ from os import environ
25
+ from sys import path
26
+
27
+ if dirname(__file__) not in path:
28
+ path.append(dirname(__file__))
29
+ if dirname(dirname(__file__)) not in path:
30
+ path.append(dirname(dirname(__file__)))
31
+
32
+ try:
33
+ from .ocrplugin import OCRPlugin, OCRError
34
+ except:
35
+ from engines.ocrplugin import OCRPlugin, OCRError
36
+
37
+ _ENDPOINT = 'https://api.api-ninjas.com/v1/imagetotext'
38
+ _FREE_LIMIT = 200 * 1024 # 200 KB on the free tier.
39
+ _PREMIUM_LIMIT = 5 * 1024 * 1024 # 5 MB on premium.
40
+
41
+
42
+ class ApiNinjasOcr(OCRPlugin):
43
+ """
44
+ ApiNinjasOcr class.
45
+ """
46
+
47
+ def __init__(self, *args, **kwargs):
48
+ """
49
+ :param api: API Ninjas key (or ``api=``, or the API_NINJAS_API_KEY environment variable).
50
+ :param image: image source (path, URL, PIL, bytes...).
51
+ :param kwargs: other settings (endpoint override, timeout, retries, proxy...).
52
+ """
53
+ api = kwargs.pop('api', environ.get('API_NINJAS_API_KEY', ''))
54
+ kwargs.setdefault('endpoint', _ENDPOINT)
55
+ kwargs.setdefault('timeout', 60)
56
+ super(ApiNinjasOcr, self).__init__(*args, **kwargs)
57
+ self.setOnline(True)
58
+ self.setApi(api)
59
+
60
+ # ------------------------------------------------------------------ #
61
+ # Request building #
62
+ # ------------------------------------------------------------------ #
63
+ def _fileTuple(self):
64
+ """
65
+ Multipart 'image' as JPEG/PNG (the only accepted formats).
66
+ """
67
+ kind = self.imageKind()
68
+ if kind == 'url':
69
+ from requests import get
70
+ reply = get(self.getImage(), timeout=self.getTimeout())
71
+ reply.raise_for_status()
72
+ data = reply.content
73
+ elif kind == 'array':
74
+ from io import BytesIO
75
+ from PIL import Image
76
+ buffer = BytesIO()
77
+ Image.fromarray(self.getImage()).save(buffer, format='PNG')
78
+ data = buffer.getvalue()
79
+ elif kind in ('path', 'pil', 'bytes', 'buffer'):
80
+ data = self.imageBytes()
81
+ else:
82
+ raise OCRError(
83
+ "Image source '{}' is not an existing file, URL, or "
84
+ "supported type. Check the path (the current working "
85
+ "directory matters for relative paths).".format(self.getImage()))
86
+ if data[:5] == b'%PDF-':
87
+ raise OCRError(
88
+ 'API Ninjas imagetotext accepts only JPEG/PNG images, '
89
+ 'not PDFs. Convert the page to an image first, or use '
90
+ 'a PDF-capable plugin (MistralOcr, GlmOcr, OlmOcr).')
91
+ if data[:3] == b'\xff\xd8\xff':
92
+ name, mime = 'image.jpg', 'image/jpeg'
93
+ elif data[:8] == b'\x89PNG\r\n\x1a\n':
94
+ name, mime = 'image.png', 'image/png'
95
+ else:
96
+ # Other raster formats (webp, bmp, gif...): convert to PNG.
97
+ from io import BytesIO
98
+ from PIL import Image
99
+ buffer = BytesIO()
100
+ Image.open(BytesIO(data)).convert('RGB').save(buffer, format='PNG')
101
+ data = buffer.getvalue()
102
+ name, mime = 'image.png', 'image/png'
103
+ if len(data) > _PREMIUM_LIMIT:
104
+ raise OCRError(
105
+ 'Image is {:.1f} MB but API Ninjas accepts at most '
106
+ '5 MB (premium) / 200 KB (free tier). Downscale or recompress it first.'.format(len(data) / 1048576.0))
107
+ return name, data, mime
108
+
109
+ # ------------------------------------------------------------------ #
110
+ # Result mapping #
111
+ # ------------------------------------------------------------------ #
112
+ def _wordsFromReply(self, payload):
113
+ """
114
+ [{text, bounding_box:{x1,y1,x2,y2}}] -> unified word dicts.
115
+ Coordinates are coerced with float(): sister endpoints have
116
+ been seen returning them as strings.
117
+ """
118
+ words = []
119
+ for entry in payload if isinstance(payload, list) else []:
120
+ if not isinstance(entry, dict):
121
+ continue
122
+ text = str(entry.get('text', '')).strip()
123
+ if not text:
124
+ continue
125
+ box = entry.get('bounding_box') or {}
126
+ try:
127
+ x1 = float(box['x1'])
128
+ y1 = float(box['y1'])
129
+ x2 = float(box['x2'])
130
+ y2 = float(box['y2'])
131
+ except (KeyError, TypeError, ValueError):
132
+ continue
133
+ if x2 <= x1 or y2 <= y1:
134
+ continue
135
+ words.append(self.makeWord(
136
+ text, x1, y1, x2 - x1, y2 - y1))
137
+ return words
138
+
139
+ # ------------------------------------------------------------------ #
140
+ # OCR #
141
+ # ------------------------------------------------------------------ #
142
+ def _run(self, image, *args, **kwargs):
143
+ """
144
+ :return: list of word dicts for the base class to assemble (WORD-level pixel boxes; the base class groups them
145
+ into lines geometrically).
146
+ """
147
+ if not self.getApi():
148
+ raise OCRError(
149
+ 'No API Ninjas key. Sign up free at https://api-ninjas.com to get one instantly, then '
150
+ 'pass api=... or set the API_NINJAS_API_KEY environment variable.')
151
+ name, data, mime = self._fileTuple()
152
+ request_kwargs = {'files': {'image': (name, data, mime)}, 'headers': {'X-Api-Key': self.getApi()},
153
+ 'timeout': self.getTimeout()}
154
+ if self.getProxy():
155
+ request_kwargs['proxies'] = self.getProxy()
156
+ reply = post(self.getEndpoint(), **request_kwargs)
157
+ if reply.status_code in (401, 403):
158
+ raise OCRError(
159
+ 'API Ninjas rejected the key (HTTP {}): check it in '
160
+ 'your account at https://api-ninjas.com. API said: {}'.format(
161
+ reply.status_code, (reply.text or '')[:200]))
162
+ if reply.status_code == 400 and len(data) > _FREE_LIMIT:
163
+ raise OCRError(
164
+ 'API Ninjas rejected the upload (HTTP 400). The image '
165
+ 'is {:.0f} KB; free accounts are limited to 200 KB '
166
+ 'per image (5 MB on premium) -- recompress/downscale '
167
+ 'it or upgrade. API said: {}'.format(len(data) / 1024.0, (reply.text or '')[:200]))
168
+ if reply.status_code == 429:
169
+ raise OCRError('API Ninjas rate limit hit: ' + (reply.text or '')[:200])
170
+ if not reply.ok:
171
+ raise OCRError('HTTP {} from API Ninjas: {}'.format(
172
+ reply.status_code, (reply.text or '')[:300].strip()))
173
+ try:
174
+ payload = reply.json()
175
+ except ValueError:
176
+ raise OCRError('API Ninjas returned a non-JSON reply: ' + (reply.text or '')[:200])
177
+ if isinstance(payload, dict) and payload.get('error'):
178
+ raise OCRError('API Ninjas error: {}'.format(str(payload['error'])[:250]))
179
+ words = self._wordsFromReply(payload)
180
+ if not words:
181
+ raise OCRError('API Ninjas found no text in this image.')
182
+ return words