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.
@@ -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