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.
- AioOCR/__init__.py +56 -0
- AioOCR/engines/__init__.py +4 -0
- AioOCR/engines/api/__init__.py +4 -0
- AioOCR/engines/api/aimlapiocr.py +195 -0
- AioOCR/engines/api/apininjasocr.py +182 -0
- AioOCR/engines/api/baiduocrapi.py +275 -0
- AioOCR/engines/api/chatgptocr.py +384 -0
- AioOCR/engines/api/claudeocr.py +277 -0
- AioOCR/engines/api/easyocrorg.py +168 -0
- AioOCR/engines/api/geminiocr.py +289 -0
- AioOCR/engines/api/googleocr.py +224 -0
- AioOCR/engines/api/grokocr.py +413 -0
- AioOCR/engines/api/groqocr.py +399 -0
- AioOCR/engines/api/mistralocr.py +290 -0
- AioOCR/engines/api/nanonetsapi.py +304 -0
- AioOCR/engines/api/nvidianenotronocr.py +432 -0
- AioOCR/engines/api/nvidiapaddleocr.py +298 -0
- AioOCR/engines/api/ocrspace.py +273 -0
- AioOCR/engines/api/omniroute.py +387 -0
- AioOCR/engines/api/openrouteocr.py +305 -0
- AioOCR/engines/api/perplexityocr.py +363 -0
- AioOCR/engines/api/qwencloudocr.py +1322 -0
- AioOCR/engines/api/rapidapiapi4aiocr.py +189 -0
- AioOCR/engines/api/rapidapiocrextracttext.py +352 -0
- AioOCR/engines/api/scandocflow.py +263 -0
- AioOCR/engines/api/siliconflowocr.py +587 -0
- AioOCR/engines/languages.py +258 -0
- AioOCR/engines/local/__init__.py +4 -0
- AioOCR/engines/local/calamariocr.py +128 -0
- AioOCR/engines/local/chandraocr.py +286 -0
- AioOCR/engines/local/deepseekocr.py +422 -0
- AioOCR/engines/local/dotsocr.py +385 -0
- AioOCR/engines/local/easy.py +98 -0
- AioOCR/engines/local/glmocrhf.py +194 -0
- AioOCR/engines/local/gotocr.py +337 -0
- AioOCR/engines/local/hunyuanocrhf.py +538 -0
- AioOCR/engines/local/infinityparser.py +236 -0
- AioOCR/engines/local/kerasocr.py +69 -0
- AioOCR/engines/local/mmocrlib.py +1198 -0
- AioOCR/engines/local/monkeyocrhf.py +236 -0
- AioOCR/engines/local/monkeyocrpro.py +131 -0
- AioOCR/engines/local/nanonetsocr2.py +318 -0
- AioOCR/engines/local/nougatlib.py +353 -0
- AioOCR/engines/local/nougatocr.py +208 -0
- AioOCR/engines/local/olmocrlib.py +316 -0
- AioOCR/engines/local/openocr.py +1157 -0
- AioOCR/engines/local/paddleocrlib.py +202 -0
- AioOCR/engines/local/pytorchocr.py +401 -0
- AioOCR/engines/local/qianfanocr.py +271 -0
- AioOCR/engines/local/qwen2vl2bocr.py +235 -0
- AioOCR/engines/local/qwenvlocrlib.py +1244 -0
- AioOCR/engines/local/rapidocrlib.py +284 -0
- AioOCR/engines/local/suryaocr.py +202 -0
- AioOCR/engines/local/tesseract.py +330 -0
- AioOCR/engines/local/trocr.py +391 -0
- AioOCR/engines/local/trocrhandwritten.py +102 -0
- AioOCR/engines/local/unlimitedocr.py +406 -0
- AioOCR/engines/ocrplugin.py +845 -0
- AioOCR/ocrbase.py +52 -0
- AioOCR/requirements.txt +13 -0
- ocrroute/__init__.py +7 -0
- ocrroute/__main__.py +10 -0
- ocrroute/api/__init__.py +6 -0
- ocrroute/api/app.py +213 -0
- ocrroute/api/deps.py +20 -0
- ocrroute/api/routers/__init__.py +6 -0
- ocrroute/api/routers/admin.py +863 -0
- ocrroute/api/routers/endpoints.py +188 -0
- ocrroute/api/routers/jobs.py +158 -0
- ocrroute/api/routers/ocr.py +159 -0
- ocrroute/api/routers/runs.py +213 -0
- ocrroute/api/routers/sync.py +394 -0
- ocrroute/api/routers/system.py +52 -0
- ocrroute/api/routers/tools.py +36 -0
- ocrroute/api/routers/users.py +147 -0
- ocrroute/api/schemas/__init__.py +6 -0
- ocrroute/api/schemas/admin.py +150 -0
- ocrroute/api/schemas/ocr.py +62 -0
- ocrroute/api/security.py +69 -0
- ocrroute/assets/fonts/DejaVuSans.ttf +0 -0
- ocrroute/assets/fonts/LICENSE-DejaVu.txt +68 -0
- ocrroute/catalog/__init__.py +9 -0
- ocrroute/catalog/engines.toml +392 -0
- ocrroute/catalog/registry.py +812 -0
- ocrroute/cli/__init__.py +6 -0
- ocrroute/cli/commands/__init__.py +6 -0
- ocrroute/cli/main.py +1120 -0
- ocrroute/compat/__init__.py +9 -0
- ocrroute/compat/ocrbase.py +78 -0
- ocrroute/compat/py23.py +89 -0
- ocrroute/config.py +94 -0
- ocrroute/crypto.py +82 -0
- ocrroute/db/__init__.py +9 -0
- ocrroute/db/base.py +17 -0
- ocrroute/db/models.py +354 -0
- ocrroute/db/repo/__init__.py +10 -0
- ocrroute/db/repo/engines.py +69 -0
- ocrroute/db/repo/stats.py +152 -0
- ocrroute/db/repo/usage.py +98 -0
- ocrroute/db/session.py +93 -0
- ocrroute/desktop/__init__.py +4 -0
- ocrroute/desktop/__main__.py +9 -0
- ocrroute/desktop/app.py +337 -0
- ocrroute/desktop/client.py +245 -0
- ocrroute/desktop/i18n.py +84 -0
- ocrroute/desktop/icons.py +85 -0
- ocrroute/desktop/pages/__init__.py +4 -0
- ocrroute/desktop/pages/base.py +102 -0
- ocrroute/desktop/pages/dashboard.py +149 -0
- ocrroute/desktop/pages/endpoints.py +180 -0
- ocrroute/desktop/pages/misc.py +191 -0
- ocrroute/desktop/pages/scan.py +603 -0
- ocrroute/desktop/pages/tables.py +661 -0
- ocrroute/desktop/server.py +110 -0
- ocrroute/desktop/state.py +140 -0
- ocrroute/desktop/styles/dark.qss +66 -0
- ocrroute/desktop/styles/light.qss +66 -0
- ocrroute/desktop/widgets/__init__.py +6 -0
- ocrroute/desktop/widgets/image_viewer.py +177 -0
- ocrroute/desktop/widgets/region_capture.py +65 -0
- ocrroute/desktop/workers/__init__.py +83 -0
- ocrroute/edition.py +38 -0
- ocrroute/enginelib.py +312 -0
- ocrroute/enginelib_probe.py +80 -0
- ocrroute/errors.py +237 -0
- ocrroute/i18n/__init__.py +134 -0
- ocrroute/i18n/ar.json +574 -0
- ocrroute/i18n/de.json +574 -0
- ocrroute/i18n/en.json +574 -0
- ocrroute/i18n/es.json +574 -0
- ocrroute/i18n/fr.json +574 -0
- ocrroute/i18n/it.json +574 -0
- ocrroute/i18n/pt.json +574 -0
- ocrroute/i18n/ru.json +574 -0
- ocrroute/i18n/zh.json +574 -0
- ocrroute/ids.py +22 -0
- ocrroute/logsetup.py +53 -0
- ocrroute/opencv_alias.py +86 -0
- ocrroute/paddleenv.py +171 -0
- ocrroute/panel/__init__.py +6 -0
- ocrroute/panel/app.py +586 -0
- ocrroute/panel/auth.py +105 -0
- ocrroute/panel/static/apple-touch-icon.png +0 -0
- ocrroute/panel/static/favicon-32.png +0 -0
- ocrroute/panel/static/favicon-dark.svg +1 -0
- ocrroute/panel/static/favicon.ico +0 -0
- ocrroute/panel/static/favicon.svg +1 -0
- ocrroute/panel/static/icon-192.png +0 -0
- ocrroute/panel/static/icon-512.png +0 -0
- ocrroute/panel/static/panel.css +279 -0
- ocrroute/panel/static/panel.js +318 -0
- ocrroute/panel/static/site.webmanifest +1 -0
- ocrroute/panel/static/vendor/alpine.min.js +5 -0
- ocrroute/panel/static/vendor/bootstrap-icons.min.css +5 -0
- ocrroute/panel/static/vendor/bootstrap.bundle.min.js +7 -0
- ocrroute/panel/static/vendor/bootstrap.min.css +6 -0
- ocrroute/panel/static/vendor/chart.umd.js +14 -0
- ocrroute/panel/static/vendor/fonts/bootstrap-icons.woff +0 -0
- ocrroute/panel/static/vendor/fonts/bootstrap-icons.woff2 +0 -0
- ocrroute/panel/static/vendor/htmx.min.js +1 -0
- ocrroute/panel/templates/base.html +55 -0
- ocrroute/panel/templates/batch.html +32 -0
- ocrroute/panel/templates/cluster.html +271 -0
- ocrroute/panel/templates/doctor.html +13 -0
- ocrroute/panel/templates/empty.html +2 -0
- ocrroute/panel/templates/endpoints.html +109 -0
- ocrroute/panel/templates/engines.html +43 -0
- ocrroute/panel/templates/keys.html +22 -0
- ocrroute/panel/templates/login.html +20 -0
- ocrroute/panel/templates/overview.html +43 -0
- ocrroute/panel/templates/partials/sidebar.html +19 -0
- ocrroute/panel/templates/playground.html +85 -0
- ocrroute/panel/templates/providers.html +104 -0
- ocrroute/panel/templates/routes.html +125 -0
- ocrroute/panel/templates/run_detail.html +36 -0
- ocrroute/panel/templates/runs.html +14 -0
- ocrroute/panel/templates/settings.html +39 -0
- ocrroute/panel/templates/setup.html +21 -0
- ocrroute/panel/templates/tools.html +13 -0
- ocrroute/panel/templates/usage.html +18 -0
- ocrroute/pipeline/__init__.py +6 -0
- ocrroute/pipeline/export/__init__.py +30 -0
- ocrroute/pipeline/export/alto.py +47 -0
- ocrroute/pipeline/export/csvxlsx.py +61 -0
- ocrroute/pipeline/export/docx.py +51 -0
- ocrroute/pipeline/export/hocr.py +41 -0
- ocrroute/pipeline/export/markdown.py +20 -0
- ocrroute/pipeline/export/plain.py +19 -0
- ocrroute/pipeline/export/searchable_pdf.py +100 -0
- ocrroute/pipeline/input.py +421 -0
- ocrroute/pipeline/overlay.py +34 -0
- ocrroute/pipeline/postprocess.py +91 -0
- ocrroute/pipeline/preprocess.py +316 -0
- ocrroute/routing/__init__.py +4 -0
- ocrroute/routing/breaker.py +127 -0
- ocrroute/routing/candidates.py +792 -0
- ocrroute/routing/consensus.py +73 -0
- ocrroute/routing/cost.py +17 -0
- ocrroute/routing/explain.py +190 -0
- ocrroute/routing/router.py +538 -0
- ocrroute/routing/strategies/__init__.py +215 -0
- ocrroute/routing/templates.py +75 -0
- ocrroute/runtime/__init__.py +6 -0
- ocrroute/runtime/cache.py +57 -0
- ocrroute/runtime/context.py +194 -0
- ocrroute/runtime/doctor.py +111 -0
- ocrroute/runtime/endpoints.py +448 -0
- ocrroute/runtime/engineinstall.py +111 -0
- ocrroute/runtime/executor.py +1439 -0
- ocrroute/runtime/jobs.py +186 -0
- ocrroute/runtime/lifecycle.py +152 -0
- ocrroute/runtime/limits.py +73 -0
- ocrroute/runtime/maintenance.py +200 -0
- ocrroute/runtime/sample.py +30 -0
- ocrroute/runtime/tunnelinstall.py +161 -0
- ocrroute/runtime/webhooks.py +31 -0
- ocrroute/selftest.py +110 -0
- ocrroute/stdio.py +148 -0
- ocrroute/sync.py +1461 -0
- ocrroute/tools/README.md +30 -0
- ocrroute/tools/__init__.py +13 -0
- ocrroute/tools/base.py +42 -0
- ocrroute/tools/builtin/__init__.py +4 -0
- ocrroute/tools/registry.py +53 -0
- ocrroute/version.py +7 -0
- ocrroute-0.8.0.dist-info/METADATA +227 -0
- ocrroute-0.8.0.dist-info/RECORD +231 -0
- ocrroute-0.8.0.dist-info/WHEEL +5 -0
- ocrroute-0.8.0.dist-info/entry_points.txt +2 -0
- ocrroute-0.8.0.dist-info/licenses/LICENSE +18 -0
- 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,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
|