multixtract 0.1.1__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.
- multixtract/__init__.py +52 -0
- multixtract/chunking.py +297 -0
- multixtract/cli.py +75 -0
- multixtract/extraction.py +44 -0
- multixtract/extractors/__init__.py +84 -0
- multixtract/extractors/_image_utils.py +157 -0
- multixtract/extractors/docx.py +375 -0
- multixtract/extractors/eml.py +187 -0
- multixtract/extractors/epub.py +142 -0
- multixtract/extractors/excel.py +321 -0
- multixtract/extractors/html.py +127 -0
- multixtract/extractors/image.py +149 -0
- multixtract/extractors/legacy.py +148 -0
- multixtract/extractors/markdown.py +104 -0
- multixtract/extractors/pdf.py +347 -0
- multixtract/extractors/pptx.py +303 -0
- multixtract/extractors/registry.py +72 -0
- multixtract/extractors/rtf.py +54 -0
- multixtract/extractors/text.py +50 -0
- multixtract/filters.py +181 -0
- multixtract/interfaces.py +151 -0
- multixtract/pipeline.py +250 -0
- multixtract/providers/__init__.py +38 -0
- multixtract/providers/_utils.py +32 -0
- multixtract/providers/azure.py +91 -0
- multixtract/providers/llama.py +151 -0
- multixtract/providers/openai.py +125 -0
- multixtract/providers/qwen2vl.py +158 -0
- multixtract/providers/smolvlm.py +152 -0
- multixtract/providers/storage.py +97 -0
- multixtract/py.typed +1 -0
- multixtract/vision.py +120 -0
- multixtract-0.1.1.dist-info/METADATA +419 -0
- multixtract-0.1.1.dist-info/RECORD +37 -0
- multixtract-0.1.1.dist-info/WHEEL +4 -0
- multixtract-0.1.1.dist-info/entry_points.txt +2 -0
- multixtract-0.1.1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""Qwen2.5-VL vision provider (optional extra: ``pip install multixtract[qwen2vl]``).
|
|
2
|
+
|
|
3
|
+
Runs Qwen2.5-VL fully offline via HuggingFace transformers — no cloud, no API
|
|
4
|
+
key. It is the recommended local vision model for document understanding in 2025:
|
|
5
|
+
it leads the 7B class on DocVQA, ChartQA, TextVQA, and OCR benchmarks, supports
|
|
6
|
+
dynamic image resolutions natively, and has first-class transformers integration.
|
|
7
|
+
|
|
8
|
+
A CUDA GPU with 16–24 GB VRAM is recommended for BF16. For CPU or low-VRAM
|
|
9
|
+
environments use the 3B variant (``Qwen/Qwen2.5-VL-3B-Instruct``) or a 4-bit
|
|
10
|
+
quantised GGUF checkpoint.
|
|
11
|
+
|
|
12
|
+
Example::
|
|
13
|
+
|
|
14
|
+
from multixtract import extract_document
|
|
15
|
+
from multixtract.providers import Qwen2VLVisionModel
|
|
16
|
+
|
|
17
|
+
vision = Qwen2VLVisionModel() # default: 7B
|
|
18
|
+
_, images = extract_document("report.pdf")
|
|
19
|
+
for img in images:
|
|
20
|
+
r = vision.analyze(img["image_bytes"], img["ext"])
|
|
21
|
+
print(r.caption, "|", r.description)
|
|
22
|
+
"""
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import logging
|
|
26
|
+
import threading
|
|
27
|
+
from typing import Optional
|
|
28
|
+
|
|
29
|
+
from ..interfaces import VisionModel, VisionResult
|
|
30
|
+
from ..vision import DEFAULT_SYSTEM_PROMPT, parse_vision_response
|
|
31
|
+
from ._utils import _infer_device, _open_image_rgb
|
|
32
|
+
|
|
33
|
+
log = logging.getLogger("multixtract")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class Qwen2VLVisionModel(VisionModel):
|
|
37
|
+
"""Offline ``VisionModel`` backed by Qwen2.5-VL (transformers).
|
|
38
|
+
|
|
39
|
+
Recommended local model for document/chart/diagram understanding. Leads the
|
|
40
|
+
7B class on DocVQA, ChartQA, and TextVQA as of 2025. Drop-in replacement for
|
|
41
|
+
cloud providers — returns the same ``VisionResult`` structure.
|
|
42
|
+
|
|
43
|
+
Heavy dependencies (``torch`` / ``transformers`` / ``qwen-vl-utils``) are
|
|
44
|
+
imported lazily; simply importing this module never pulls them in.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
def __init__(
|
|
48
|
+
self,
|
|
49
|
+
model_id: str = "Qwen/Qwen2.5-VL-7B-Instruct",
|
|
50
|
+
system_prompt: str = DEFAULT_SYSTEM_PROMPT,
|
|
51
|
+
max_new_tokens: int = 512,
|
|
52
|
+
device: Optional[str] = None,
|
|
53
|
+
torch_dtype: str = "bfloat16",
|
|
54
|
+
load_in_4bit: bool = False,
|
|
55
|
+
model=None,
|
|
56
|
+
processor=None,
|
|
57
|
+
) -> None:
|
|
58
|
+
"""Load a Qwen2.5-VL model.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
model_id: HuggingFace model id. Recommended checkpoints:
|
|
62
|
+
``Qwen/Qwen2.5-VL-7B-Instruct`` (default, best accuracy),
|
|
63
|
+
``Qwen/Qwen2.5-VL-3B-Instruct`` (lower VRAM / faster CPU).
|
|
64
|
+
system_prompt: Instruction sent with every image. Defaults to the
|
|
65
|
+
shared CAPTION/OCR_TEXT/DESCRIPTION prompt.
|
|
66
|
+
max_new_tokens: Generation budget per image.
|
|
67
|
+
device: ``"cuda"`` or ``"cpu"``. Auto-detected when omitted.
|
|
68
|
+
torch_dtype: dtype for GPU (``"bfloat16"`` recommended for Qwen2.5-VL).
|
|
69
|
+
load_in_4bit: 4-bit quantised load (needs ``bitsandbytes``); halves
|
|
70
|
+
VRAM — useful on 8–12 GB cards.
|
|
71
|
+
model / processor: Pre-built instances to reuse (skips loading).
|
|
72
|
+
"""
|
|
73
|
+
self.model_id = model_id
|
|
74
|
+
self.system_prompt = system_prompt
|
|
75
|
+
self.max_new_tokens = max_new_tokens
|
|
76
|
+
self._lock = threading.Lock()
|
|
77
|
+
|
|
78
|
+
if model is not None and processor is not None:
|
|
79
|
+
self._model = model
|
|
80
|
+
self._processor = processor
|
|
81
|
+
self.device = device or _infer_device()
|
|
82
|
+
return
|
|
83
|
+
|
|
84
|
+
try:
|
|
85
|
+
import torch
|
|
86
|
+
from transformers import AutoProcessor, Qwen2_5_VLForConditionalGeneration
|
|
87
|
+
except ImportError as exc:
|
|
88
|
+
raise ImportError(
|
|
89
|
+
"Qwen2.5-VL support requires transformers + torch: "
|
|
90
|
+
"pip install 'multixtract[qwen2vl]'"
|
|
91
|
+
) from exc
|
|
92
|
+
|
|
93
|
+
self.device = device or _infer_device()
|
|
94
|
+
|
|
95
|
+
load_kwargs: dict = {"low_cpu_mem_usage": True}
|
|
96
|
+
if load_in_4bit:
|
|
97
|
+
load_kwargs["load_in_4bit"] = True
|
|
98
|
+
load_kwargs["device_map"] = "auto" # required by bitsandbytes 4-bit
|
|
99
|
+
else:
|
|
100
|
+
dtype = getattr(torch, torch_dtype, torch.bfloat16)
|
|
101
|
+
load_kwargs["torch_dtype"] = dtype
|
|
102
|
+
load_kwargs["device_map"] = self.device
|
|
103
|
+
|
|
104
|
+
self._processor = AutoProcessor.from_pretrained(model_id)
|
|
105
|
+
self._model = Qwen2_5_VLForConditionalGeneration.from_pretrained(
|
|
106
|
+
model_id, **load_kwargs
|
|
107
|
+
)
|
|
108
|
+
self._model.eval()
|
|
109
|
+
|
|
110
|
+
def analyze(
|
|
111
|
+
self,
|
|
112
|
+
image_bytes: bytes,
|
|
113
|
+
ext: str = "png",
|
|
114
|
+
width: int = 0,
|
|
115
|
+
height: int = 0,
|
|
116
|
+
) -> VisionResult:
|
|
117
|
+
"""Describe one image. Never raises — returns an empty result on failure."""
|
|
118
|
+
try:
|
|
119
|
+
import torch
|
|
120
|
+
|
|
121
|
+
with _open_image_rgb(image_bytes) as image:
|
|
122
|
+
messages = [
|
|
123
|
+
{
|
|
124
|
+
"role": "user",
|
|
125
|
+
"content": [
|
|
126
|
+
{"type": "image", "image": image},
|
|
127
|
+
{"type": "text", "text": self.system_prompt},
|
|
128
|
+
],
|
|
129
|
+
}
|
|
130
|
+
]
|
|
131
|
+
with self._lock:
|
|
132
|
+
text_prompt = self._processor.apply_chat_template(
|
|
133
|
+
messages, tokenize=False, add_generation_prompt=True
|
|
134
|
+
)
|
|
135
|
+
inputs = self._processor(
|
|
136
|
+
text=[text_prompt],
|
|
137
|
+
images=[image],
|
|
138
|
+
return_tensors="pt",
|
|
139
|
+
padding=True,
|
|
140
|
+
)
|
|
141
|
+
inputs = {k: v.to(self._model.device) for k, v in inputs.items()}
|
|
142
|
+
with torch.inference_mode():
|
|
143
|
+
output_ids = self._model.generate(
|
|
144
|
+
**inputs,
|
|
145
|
+
max_new_tokens=self.max_new_tokens,
|
|
146
|
+
do_sample=False,
|
|
147
|
+
)
|
|
148
|
+
# Strip the input tokens from the output
|
|
149
|
+
input_len = inputs["input_ids"].shape[1]
|
|
150
|
+
generated = output_ids[:, input_len:]
|
|
151
|
+
text = self._processor.batch_decode(
|
|
152
|
+
generated, skip_special_tokens=True, clean_up_tokenization_spaces=False
|
|
153
|
+
)[0]
|
|
154
|
+
|
|
155
|
+
return parse_vision_response(text)
|
|
156
|
+
except Exception: # noqa: BLE001
|
|
157
|
+
log.warning("Qwen2VL analyze failed", exc_info=True)
|
|
158
|
+
return VisionResult()
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""SmolVLM vision provider (optional extra: ``pip install multixtract[smolvlm]``).
|
|
2
|
+
|
|
3
|
+
Runs SmolVLM fully offline via HuggingFace transformers — no cloud, no API key.
|
|
4
|
+
At 2.2B parameters it is the recommended CPU-friendly option, offering meaningfully
|
|
5
|
+
better DocVQA and ChartQA accuracy while still running on CPU
|
|
6
|
+
without impractical wait times.
|
|
7
|
+
|
|
8
|
+
Uses the standard ``AutoProcessor`` + ``AutoModelForVision2Seq`` API — no
|
|
9
|
+
``trust_remote_code`` required.
|
|
10
|
+
|
|
11
|
+
Example::
|
|
12
|
+
|
|
13
|
+
from multixtract import extract_document
|
|
14
|
+
from multixtract.providers import SmolVLMVisionModel
|
|
15
|
+
|
|
16
|
+
vision = SmolVLMVisionModel() # downloads ~4 GB on first use
|
|
17
|
+
_, images = extract_document("report.pdf")
|
|
18
|
+
for img in images:
|
|
19
|
+
r = vision.analyze(img["image_bytes"], img["ext"])
|
|
20
|
+
print(r.caption, "|", r.ocr_text)
|
|
21
|
+
"""
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import logging
|
|
25
|
+
import threading
|
|
26
|
+
from typing import Optional
|
|
27
|
+
|
|
28
|
+
from ..interfaces import VisionModel, VisionResult
|
|
29
|
+
from ..vision import DEFAULT_SYSTEM_PROMPT, parse_vision_response
|
|
30
|
+
from ._utils import _infer_device, _open_image_rgb
|
|
31
|
+
|
|
32
|
+
log = logging.getLogger("multixtract")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class SmolVLMVisionModel(VisionModel):
|
|
36
|
+
"""Offline ``VisionModel`` backed by SmolVLM-2.2B-Instruct.
|
|
37
|
+
|
|
38
|
+
Best choice for CPU-only or memory-constrained environments. At 2.2B
|
|
39
|
+
parameters it significantly outperforms competing models on document and chart
|
|
40
|
+
benchmarks while remaining practical on CPU. No ``trust_remote_code``
|
|
41
|
+
required — uses the standard transformers vision-language API.
|
|
42
|
+
|
|
43
|
+
Heavy dependencies (``torch`` / ``transformers``) are imported lazily;
|
|
44
|
+
simply importing this module never pulls them in.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
def __init__(
|
|
48
|
+
self,
|
|
49
|
+
model_id: str = "HuggingFaceTB/SmolVLM-2.2B-Instruct",
|
|
50
|
+
system_prompt: str = DEFAULT_SYSTEM_PROMPT,
|
|
51
|
+
max_new_tokens: int = 512,
|
|
52
|
+
device: Optional[str] = None,
|
|
53
|
+
torch_dtype: str = "bfloat16",
|
|
54
|
+
load_in_4bit: bool = False,
|
|
55
|
+
model=None,
|
|
56
|
+
processor=None,
|
|
57
|
+
) -> None:
|
|
58
|
+
"""Load SmolVLM.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
model_id: HuggingFace model id. Defaults to
|
|
62
|
+
``HuggingFaceTB/SmolVLM-2.2B-Instruct``.
|
|
63
|
+
``HuggingFaceTB/SmolVLM-500M-Instruct`` is available for
|
|
64
|
+
extremely constrained environments (lower accuracy).
|
|
65
|
+
system_prompt: Instruction sent with every image. Defaults to the
|
|
66
|
+
shared, parseable CAPTION/OCR_TEXT/DESCRIPTION prompt.
|
|
67
|
+
max_new_tokens: Generation budget per image.
|
|
68
|
+
device: ``"cuda"`` or ``"cpu"``. Auto-detected when omitted.
|
|
69
|
+
torch_dtype: dtype name used on GPU (``"bfloat16"`` recommended).
|
|
70
|
+
load_in_4bit: 4-bit quantised load (needs ``bitsandbytes``).
|
|
71
|
+
model / processor: Pre-built instances to reuse (skips loading).
|
|
72
|
+
"""
|
|
73
|
+
self.model_id = model_id
|
|
74
|
+
self.system_prompt = system_prompt
|
|
75
|
+
self.max_new_tokens = max_new_tokens
|
|
76
|
+
|
|
77
|
+
self._lock = threading.Lock()
|
|
78
|
+
|
|
79
|
+
if model is not None and processor is not None:
|
|
80
|
+
self._model, self._processor = model, processor
|
|
81
|
+
self.device = device or _infer_device()
|
|
82
|
+
return
|
|
83
|
+
|
|
84
|
+
try:
|
|
85
|
+
import torch
|
|
86
|
+
from transformers import AutoModelForVision2Seq, AutoProcessor
|
|
87
|
+
except ImportError as exc:
|
|
88
|
+
raise ImportError(
|
|
89
|
+
"SmolVLM support requires transformers + torch: "
|
|
90
|
+
"pip install 'multixtract[smolvlm]'"
|
|
91
|
+
) from exc
|
|
92
|
+
|
|
93
|
+
self.device = device or _infer_device()
|
|
94
|
+
load_kwargs = {"low_cpu_mem_usage": True}
|
|
95
|
+
if self.device == "cuda":
|
|
96
|
+
load_kwargs["torch_dtype"] = getattr(torch, torch_dtype, torch.bfloat16)
|
|
97
|
+
else:
|
|
98
|
+
load_kwargs["torch_dtype"] = torch.float32
|
|
99
|
+
if load_in_4bit:
|
|
100
|
+
load_kwargs["load_in_4bit"] = True
|
|
101
|
+
load_kwargs["device_map"] = "auto" # required by bitsandbytes 4-bit
|
|
102
|
+
else:
|
|
103
|
+
load_kwargs["device_map"] = self.device
|
|
104
|
+
|
|
105
|
+
self._processor = AutoProcessor.from_pretrained(model_id)
|
|
106
|
+
self._model = AutoModelForVision2Seq.from_pretrained(model_id, **load_kwargs)
|
|
107
|
+
self._model.eval()
|
|
108
|
+
|
|
109
|
+
def analyze(
|
|
110
|
+
self,
|
|
111
|
+
image_bytes: bytes,
|
|
112
|
+
ext: str = "png",
|
|
113
|
+
width: int = 0,
|
|
114
|
+
height: int = 0,
|
|
115
|
+
) -> VisionResult:
|
|
116
|
+
"""Describe one image. Never raises — returns an empty result on failure."""
|
|
117
|
+
try:
|
|
118
|
+
import torch
|
|
119
|
+
|
|
120
|
+
with _open_image_rgb(image_bytes) as image:
|
|
121
|
+
messages = [
|
|
122
|
+
{
|
|
123
|
+
"role": "user",
|
|
124
|
+
"content": [
|
|
125
|
+
{"type": "image"},
|
|
126
|
+
{"type": "text", "text": self.system_prompt},
|
|
127
|
+
],
|
|
128
|
+
}
|
|
129
|
+
]
|
|
130
|
+
with self._lock:
|
|
131
|
+
input_text = self._processor.apply_chat_template(
|
|
132
|
+
messages, add_generation_prompt=True
|
|
133
|
+
)
|
|
134
|
+
inputs = self._processor(
|
|
135
|
+
images=[image], text=input_text, return_tensors="pt"
|
|
136
|
+
).to(self._model.device)
|
|
137
|
+
with torch.inference_mode():
|
|
138
|
+
output_ids = self._model.generate(
|
|
139
|
+
**inputs,
|
|
140
|
+
max_new_tokens=self.max_new_tokens,
|
|
141
|
+
do_sample=False,
|
|
142
|
+
)
|
|
143
|
+
# Decode only the newly generated tokens (skip the prompt).
|
|
144
|
+
prompt_len = inputs["input_ids"].shape[-1]
|
|
145
|
+
text = self._processor.decode(
|
|
146
|
+
output_ids[0][prompt_len:], skip_special_tokens=True
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
return parse_vision_response(text)
|
|
150
|
+
except Exception: # noqa: BLE001 — never break the caller/pipeline
|
|
151
|
+
log.warning("SmolVLM analyze failed", exc_info=True)
|
|
152
|
+
return VisionResult()
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Storage providers.
|
|
2
|
+
|
|
3
|
+
``LocalDiskStore`` is dependency-free (core). ``AzureBlobStore`` requires the
|
|
4
|
+
``[azure]`` extra. Both satisfy the :class:`~multixtract.interfaces.BlobStore`
|
|
5
|
+
protocol so the pipeline treats them identically.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
from typing import Optional
|
|
12
|
+
|
|
13
|
+
from ..interfaces import BlobStore
|
|
14
|
+
|
|
15
|
+
# Separator-only JSON — ~30-40%% smaller than the default readable form.
|
|
16
|
+
_COMPACT_SEPARATORS = (",", ":")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _dumps(obj, compact: bool) -> str:
|
|
20
|
+
if compact:
|
|
21
|
+
return json.dumps(obj, separators=_COMPACT_SEPARATORS, ensure_ascii=False)
|
|
22
|
+
return json.dumps(obj, indent=2, ensure_ascii=False)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class LocalDiskStore(BlobStore):
|
|
26
|
+
"""Persist outputs to the local filesystem under *root*."""
|
|
27
|
+
|
|
28
|
+
def __init__(self, root: str) -> None:
|
|
29
|
+
self.root = os.path.abspath(root)
|
|
30
|
+
|
|
31
|
+
def _full(self, path: str) -> str:
|
|
32
|
+
full = os.path.join(self.root, path)
|
|
33
|
+
os.makedirs(os.path.dirname(full), exist_ok=True)
|
|
34
|
+
return full
|
|
35
|
+
|
|
36
|
+
def put_bytes(self, path: str, data: bytes, content_type: str = "") -> str:
|
|
37
|
+
full = self._full(path)
|
|
38
|
+
with open(full, "wb") as fh:
|
|
39
|
+
fh.write(data)
|
|
40
|
+
return full
|
|
41
|
+
|
|
42
|
+
def put_json(self, path: str, obj: object, compact: bool = False) -> str:
|
|
43
|
+
full = self._full(path)
|
|
44
|
+
with open(full, "w", encoding="utf-8") as fh:
|
|
45
|
+
fh.write(_dumps(obj, compact))
|
|
46
|
+
return full
|
|
47
|
+
|
|
48
|
+
def exists(self, path: str) -> bool:
|
|
49
|
+
return os.path.exists(os.path.join(self.root, path))
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class AzureBlobStore(BlobStore):
|
|
53
|
+
"""Persist outputs to an Azure Blob Storage container.
|
|
54
|
+
|
|
55
|
+
Provide either an account ``credential`` (e.g. a ClientSecretCredential or
|
|
56
|
+
DefaultAzureCredential) or a ready ``blob_service_client``. Never embed
|
|
57
|
+
secrets in code — pass credentials in from a secret store.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
def __init__(
|
|
61
|
+
self,
|
|
62
|
+
container: str,
|
|
63
|
+
prefix: str = "",
|
|
64
|
+
account_url: Optional[str] = None,
|
|
65
|
+
credential=None,
|
|
66
|
+
blob_service_client=None,
|
|
67
|
+
) -> None:
|
|
68
|
+
if blob_service_client is None:
|
|
69
|
+
from azure.storage.blob import BlobServiceClient
|
|
70
|
+
if not account_url:
|
|
71
|
+
raise ValueError("account_url is required when blob_service_client is not given")
|
|
72
|
+
blob_service_client = BlobServiceClient(account_url=account_url, credential=credential)
|
|
73
|
+
self._svc = blob_service_client
|
|
74
|
+
self.container = container
|
|
75
|
+
self.prefix = prefix.strip("/")
|
|
76
|
+
|
|
77
|
+
def _blob(self, path: str):
|
|
78
|
+
name = f"{self.prefix}/{path}" if self.prefix else path
|
|
79
|
+
return self._svc.get_blob_client(container=self.container, blob=name), name
|
|
80
|
+
|
|
81
|
+
def put_bytes(self, path: str, data: bytes, content_type: str = "") -> str:
|
|
82
|
+
from azure.storage.blob import ContentSettings
|
|
83
|
+
client, name = self._blob(path)
|
|
84
|
+
settings = ContentSettings(content_type=content_type) if content_type else None
|
|
85
|
+
client.upload_blob(data, overwrite=True, content_settings=settings)
|
|
86
|
+
return name
|
|
87
|
+
|
|
88
|
+
def put_json(self, path: str, obj: object, compact: bool = False) -> str:
|
|
89
|
+
return self.put_bytes(
|
|
90
|
+
path,
|
|
91
|
+
_dumps(obj, compact).encode("utf-8"),
|
|
92
|
+
content_type="application/json",
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
def exists(self, path: str) -> bool:
|
|
96
|
+
client, _ = self._blob(path)
|
|
97
|
+
return client.exists()
|
multixtract/py.typed
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
multixtract/vision.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""Vendor-neutral vision helpers.
|
|
2
|
+
|
|
3
|
+
These utilities are reused by concrete VisionModel providers:
|
|
4
|
+
* a default system prompt that yields CAPTION / OCR_TEXT / DESCRIPTION
|
|
5
|
+
* base64 data-URL encoding with a fast path + PIL resize fallback
|
|
6
|
+
* a parser that turns the structured response into a VisionResult
|
|
7
|
+
|
|
8
|
+
No vendor SDK is imported here — only Pillow (already a core dependency).
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import base64
|
|
13
|
+
import io
|
|
14
|
+
from typing import Dict
|
|
15
|
+
|
|
16
|
+
from PIL import Image
|
|
17
|
+
|
|
18
|
+
from .interfaces import VisionResult
|
|
19
|
+
|
|
20
|
+
# Default prompt — providers may override. Produces a strict, parseable format.
|
|
21
|
+
# Tuned for technical / engineering documents and works across vision backends
|
|
22
|
+
# (GPT-4o, Azure OpenAI, local Llama 3.2 Vision). DESCRIPTION may span multiple lines —
|
|
23
|
+
# parse_vision_response captures the full block.
|
|
24
|
+
DEFAULT_SYSTEM_PROMPT = (
|
|
25
|
+
"You are a meticulous image-analysis assistant for technical and engineering "
|
|
26
|
+
"documents (reports, datasheets, test results, CAD/schematics, charts, and "
|
|
27
|
+
"tables). Analyze the single image and respond using EXACTLY the three "
|
|
28
|
+
"labelled sections below, in this order, with nothing before or after them:\n\n"
|
|
29
|
+
"CAPTION: <one concise sentence naming what the image is>\n"
|
|
30
|
+
"OCR_TEXT: <every piece of visible text verbatim — titles, labels, numbers "
|
|
31
|
+
"with units, axis ticks, legend entries, callouts, and table cells; separate "
|
|
32
|
+
"items with semicolons; write NONE if there is no text>\n"
|
|
33
|
+
"DESCRIPTION: <a thorough description: first state the image type (photograph "
|
|
34
|
+
"/ line chart / bar chart / scatter plot / schematic / circuit diagram / "
|
|
35
|
+
"engineering drawing / table / flowchart / screenshot), then the subject and "
|
|
36
|
+
"its components, the quantitative data or measurements shown, and any trends, "
|
|
37
|
+
"comparisons, anomalies, relationships, or engineering significance. Use "
|
|
38
|
+
"multiple sentences and do NOT invent details that are not visible.>"
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
# MIME types commonly supported natively by vision models via data URL.
|
|
42
|
+
_SUPPORTED_MIME: Dict[str, str] = {
|
|
43
|
+
"png": "image/png", "jpeg": "image/jpeg", "jpg": "image/jpeg",
|
|
44
|
+
"gif": "image/gif", "webp": "image/webp",
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
_MAX_VISION_DIM = 2048 # Typical internal cap for "high" detail mode.
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def to_data_url(image_bytes: bytes, ext: str, width: int = 0, height: int = 0) -> str:
|
|
51
|
+
"""Convert raw image bytes to a base64 data URL.
|
|
52
|
+
|
|
53
|
+
Fast path: when dimensions are known, the format is supported, and the
|
|
54
|
+
image is within the size cap, skip PIL entirely (pure base64 encode).
|
|
55
|
+
Slow path: open with PIL to resize (> max dim) or convert unsupported
|
|
56
|
+
formats to PNG.
|
|
57
|
+
"""
|
|
58
|
+
mime = _SUPPORTED_MIME.get(ext.lower())
|
|
59
|
+
|
|
60
|
+
if mime and width > 0 and height > 0 and max(width, height) <= _MAX_VISION_DIM:
|
|
61
|
+
return f"data:{mime};base64,{base64.b64encode(image_bytes).decode()}"
|
|
62
|
+
|
|
63
|
+
img = Image.open(io.BytesIO(image_bytes))
|
|
64
|
+
try:
|
|
65
|
+
width, height = img.size
|
|
66
|
+
if max(width, height) > _MAX_VISION_DIM or mime is None:
|
|
67
|
+
orig = img
|
|
68
|
+
img = orig.convert("RGB")
|
|
69
|
+
orig.close()
|
|
70
|
+
if max(width, height) > _MAX_VISION_DIM:
|
|
71
|
+
img.thumbnail((_MAX_VISION_DIM, _MAX_VISION_DIM), Image.Resampling.LANCZOS)
|
|
72
|
+
png_buffer = io.BytesIO()
|
|
73
|
+
img.save(png_buffer, format="PNG")
|
|
74
|
+
image_bytes = png_buffer.getvalue()
|
|
75
|
+
mime = "image/png"
|
|
76
|
+
finally:
|
|
77
|
+
img.close()
|
|
78
|
+
|
|
79
|
+
return f"data:{mime};base64,{base64.b64encode(image_bytes).decode()}"
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def parse_vision_response(text: str) -> VisionResult:
|
|
83
|
+
"""Parse a structured CAPTION/OCR_TEXT/DESCRIPTION response.
|
|
84
|
+
|
|
85
|
+
Each section may span multiple lines (common with local vision models);
|
|
86
|
+
a section runs until the next labelled marker. Falls back to using the whole
|
|
87
|
+
response as the description if the model did not follow the format.
|
|
88
|
+
"""
|
|
89
|
+
result = VisionResult()
|
|
90
|
+
key_map = {"CAPTION": "caption", "OCR_TEXT": "ocr_text", "DESCRIPTION": "description"}
|
|
91
|
+
buffers: Dict[str, list] = {"caption": [], "ocr_text": [], "description": []}
|
|
92
|
+
|
|
93
|
+
current = None
|
|
94
|
+
seen: set = set() # sections already opened — each is valid exactly once
|
|
95
|
+
for raw in (text or "").strip().splitlines():
|
|
96
|
+
line = raw.strip()
|
|
97
|
+
marker = None
|
|
98
|
+
for prefix, attr in key_map.items():
|
|
99
|
+
if attr not in seen and line.upper().startswith(f"{prefix}:"):
|
|
100
|
+
marker = attr
|
|
101
|
+
buffers[attr].append(line.split(":", 1)[1].strip())
|
|
102
|
+
break
|
|
103
|
+
if marker is not None:
|
|
104
|
+
current = marker
|
|
105
|
+
seen.add(marker)
|
|
106
|
+
elif current is not None and line:
|
|
107
|
+
buffers[current].append(line)
|
|
108
|
+
|
|
109
|
+
for attr, parts in buffers.items():
|
|
110
|
+
value = " ".join(p for p in parts if p).strip()
|
|
111
|
+
if value:
|
|
112
|
+
setattr(result, attr, value)
|
|
113
|
+
|
|
114
|
+
# Treat explicit "none" OCR sentinel as empty.
|
|
115
|
+
if result.ocr_text.strip().upper() in {"NONE", "N/A"}:
|
|
116
|
+
result.ocr_text = ""
|
|
117
|
+
|
|
118
|
+
if not result.description:
|
|
119
|
+
result.description = (text or "").strip()
|
|
120
|
+
return result
|