Skip to content

Commit 72b7e32

Browse files
committed
fix(extract-python): ensure pipeline run on the proper device
1 parent c34ccc1 commit 72b7e32

13 files changed

Lines changed: 147 additions & 52 deletions

File tree

‎extract-core/extract_core/__init__.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from .objects import (
88
BaseModel,
99
ConversionOutput,
10+
Device,
1011
Error,
1112
InputDoc,
1213
MarkdownDoc,
@@ -46,6 +47,7 @@
4647
"BaseModel",
4748
"BasePipelineConfig",
4849
"ConversionOutput",
50+
"Device",
4951
"DoclingPipelineConfig",
5052
"Error",
5153
"InputDoc",

‎extract-core/extract_core/docling_.py‎

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@
3535
from pydantic_core.core_schema import SerializerFunctionWrapHandler
3636

3737
from .configs import BasePipelineConfig, PipelineType
38-
from .objects import BaseModel, SupportedExt
38+
from .objects import BaseModel, Device, SupportedExt
3939
from .utils import all_subclasses
4040

4141

@@ -161,7 +161,9 @@ def _table_structure_opts_type_adapter() -> TypeAdapter:
161161

162162

163163
def _resolve_pipeline_options(
164-
pipeline_options: dict[str, Any] | None | PipelineOptions, pipeline_cls: type
164+
pipeline_options: dict[str, Any] | None | PipelineOptions,
165+
pipeline_cls: type,
166+
device: Device,
165167
) -> PipelineOptions:
166168
option_cls = _find_init_arg_type(pipeline_cls, "pipeline_options")
167169
picture_descr_opts = pipeline_options.get("picture_description_options")
@@ -198,6 +200,8 @@ def _resolve_pipeline_options(
198200
)
199201
pipeline_options["table_structure_options"] = table_structure_opts
200202
pipeline_options = option_cls.model_validate(pipeline_options)
203+
if isinstance(pipeline_options, PipelineOptions):
204+
pipeline_options.accelerator_options.device = device.to_docling()
201205
return pipeline_options
202206

203207

@@ -214,11 +218,13 @@ class DoclingFormatOption(BaseFormatOption):
214218
pipeline_cls: str
215219
pipeline_options: dict[str, Any] | None = None
216220

217-
def to_docling(self) -> BaseFormatOption: # noqa: ANN201
221+
def to_docling(self, device: Device) -> BaseFormatOption: # noqa: ANN201
218222
from docling.document_converter import FormatOption # noqa: PLC0415
219223

220224
pipeline_cls = _resolve_pipeline_cls(self.pipeline_cls)
221-
pipeline_opts = _resolve_pipeline_options(self.pipeline_options, pipeline_cls)
225+
pipeline_opts = _resolve_pipeline_options(
226+
self.pipeline_options, pipeline_cls, device
227+
)
222228
pipeline_opts = _validate_pipeline_opts(pipeline_opts)
223229
return FormatOption(
224230
pipeline_cls=pipeline_cls,

‎extract-core/extract_core/objects.py‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from pathlib import Path
1010
from typing import Annotated, Any, NoReturn, Self
1111

12+
from docling.datamodel.accelerator_options import AcceleratorDevice
1213
from icij_common.pydantic_utils import (
1314
icij_config,
1415
merge_configs,
@@ -26,6 +27,23 @@ class BaseModel(_BaseModel):
2627
model_config = base_config
2728

2829

30+
class Device(StrEnum):
31+
CPU = "cpu"
32+
CUDA = "cuda"
33+
MPS = "mps"
34+
35+
def to_docling(self) -> AcceleratorDevice:
36+
match self:
37+
case Device.CUDA:
38+
return AcceleratorDevice.CPU
39+
case Device.MPS:
40+
return AcceleratorDevice.MPS
41+
case Device.CPU:
42+
return AcceleratorDevice.CPU
43+
case _:
44+
raise ValueError(f"unsupported device {self}")
45+
46+
2947
class SupportedExt(StrEnum):
3048
ADOC = ".adoc"
3149
ASC = ".asc"

‎extract-core/extract_core/pipeline.py‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,13 @@
44

55
from icij_common.registrable import RegistrableFromConfig
66

7-
from .objects import InputDoc, OutputFormat, Result
7+
from .objects import Device, InputDoc, OutputFormat, Result
88

99

1010
class Pipeline(RegistrableFromConfig, ABC):
11+
def __init__(self, device: Device = Device.CPU):
12+
self._device = device
13+
1114
@abstractmethod
1215
async def extract_content(
1316
self, docs: Iterable[InputDoc], output_format: OutputFormat, output_path: Path

‎extract-core/tests/test_objects.py‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
1+
from docling.datamodel.accelerator_options import AcceleratorDevice, AcceleratorOptions
12
from docling.datamodel.base_models import InputFormat
23
from docling.datamodel.pipeline_options import (
34
TesseractOcrOptions,
45
ThreadedPdfPipelineOptions,
56
)
67
from docling.document_converter import PdfFormatOption
78
from extract_core import DoclingPipelineConfig, PipelineConfig
9+
from extract_core.objects import Device
810
from pydantic import TypeAdapter
911

1012

@@ -20,6 +22,7 @@ def test_docling_pipeline_config() -> None:
2022
"pipeline_options": {
2123
"ocr_options": {"kind": "tesserocr", "lang": ["auto"]},
2224
"generate_picture_images": True,
25+
"accelerator_options": {"device": "cpu"},
2326
},
2427
}
2528
},
@@ -30,11 +33,13 @@ def test_docling_pipeline_config() -> None:
3033
assert isinstance(pipeline_config, DoclingPipelineConfig)
3134
format_options = pipeline_config.format_options
3235
pdf_options = format_options[InputFormat.PDF]
33-
pdf_pipeline_options = pdf_options.to_docling()
36+
pdf_pipeline_options = pdf_options.to_docling(Device.CPU)
37+
3438
expected_options = PdfFormatOption(
3539
pipeline_options=ThreadedPdfPipelineOptions(
3640
ocr_options=TesseractOcrOptions(lang=["auto"]),
3741
generate_picture_images=True,
42+
accelerator_options=AcceleratorOptions(device=AcceleratorDevice.CPU),
3843
)
3944
)
4045
assert pdf_pipeline_options.model_dump() == expected_options.model_dump()

‎extract-python/extract_python/docling_.py‎

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
Result,
2828
Status,
2929
)
30+
from extract_core.objects import Device
3031
from icij_common.pydantic_utils import merge_configs
3132
from icij_common.registrable import FromConfig
3233
from pydantic import ConfigDict, field_serializer
@@ -41,9 +42,15 @@
4142
@Pipeline.register(PipelineType.DOCLING)
4243
class DoclingPipeline(Pipeline):
4344
def __init__(
44-
self, format_options: dict["InputFormat", DoclingFormatOption] | None = None
45+
self,
46+
format_options: dict["InputFormat", DoclingFormatOption] | None = None,
47+
*,
48+
device: Device = Device.CPU,
4549
):
46-
format_options = {k: v.to_docling() for k, v in format_options.items()}
50+
super().__init__(device)
51+
format_options = {
52+
k: v.to_docling(self._device) for k, v in format_options.items()
53+
}
4754
allowed_format = [
4855
f.to_docling() for f in DoclingPipelineConfig.supported_exts()
4956
]
@@ -66,8 +73,13 @@ async def extract_content(
6673
yield _to_result(res, doc, output_format, output_path=output_path)
6774

6875
@classmethod
69-
def _from_config(cls, config: DoclingPipelineConfig) -> FromConfig:
70-
return cls(config.format_options)
76+
def _from_config(
77+
cls,
78+
config: DoclingPipelineConfig,
79+
*,
80+
device: Device = Device.CPU,
81+
) -> FromConfig:
82+
return cls(config.format_options, device=device)
7183

7284

7385
def _to_docling(docs: Iterable[InputDoc]) -> Iterator["Path | DocumentStream"]:

‎extract-python/extract_python/marker_.py‎

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
from extract_core import BasePipelineConfig, Pipeline, PipelineType
1010
from extract_core.objects import (
11+
Device,
1112
InputDoc,
1213
MarkdownDoc,
1314
OutputFormat,
@@ -64,7 +65,13 @@ def supported_exts(cls) -> set[SupportedExt]:
6465

6566
@Pipeline.register(PipelineType.MARKER)
6667
class MarkerPipeline(Pipeline):
67-
def __init__(self, marker_config: dict[str, Any] | None = None):
68+
def __init__(
69+
self,
70+
marker_config: dict[str, Any] | None = None,
71+
*,
72+
device: Device = Device.CPU,
73+
):
74+
super().__init__(device)
6875
if marker_config is None:
6976
marker_config = dict()
7077
self._marker_config = marker_config
@@ -82,16 +89,21 @@ async def extract_content(
8289
renderer = config_parser.get_renderer()
8390
converter = PdfConverter(
8491
config=config_parser.generate_config_dict(),
85-
artifact_dict=create_model_dict(),
92+
artifact_dict=create_model_dict(device=self._device),
8693
processor_list=config_parser.get_processors(),
8794
renderer=renderer,
8895
)
8996
for doc in docs:
9097
yield await _process_doc(doc, converter, output_format, output_path)
9198

9299
@classmethod
93-
def _from_config(cls, config: MarkerPipelineConfig) -> Self:
94-
return cls(config.config)
100+
def _from_config(
101+
cls,
102+
config: MarkerPipelineConfig,
103+
*,
104+
device: Device = Device.CPU,
105+
) -> Self:
106+
return cls(config.config, device=device)
95107

96108

97109
@report_recoverable_errors(_MARKER_CONVERSION_ERRORS)

‎extract-python/extract_python/miner_u.py‎

Lines changed: 41 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import json
2+
import os
23
import shutil
34
from collections.abc import AsyncGenerator, Callable, Iterable
45
from functools import partial
@@ -19,17 +20,21 @@
1920
Result,
2021
Status,
2122
)
23+
from objects import Device
2224

2325
from .constants import ARTIFACTS, DEFAULT_MD_PAGE_SEP
24-
from .utils import path_to_artifacts_dirname
26+
from .utils import path_to_artifacts_dirname, reset_env
2527

2628
_MINER_U_CONVERSION_ERRORS = tuple()
2729
MDMakeFunction = Callable[[list, str, str], str | None]
2830

2931

3032
@Pipeline.register(PipelineType.MINER_U)
3133
class MinerUPipeline(Pipeline):
32-
def __init__(self, config: MinerUConfig, language: str):
34+
def __init__(
35+
self, config: MinerUConfig, language: str, *, device: Device = Device.CPU
36+
):
37+
super().__init__(device)
3338
self._config = config
3439
self._language = language
3540
self._md_make_fn = _parse_md_make_fn(config.backend)
@@ -39,36 +44,43 @@ async def extract_content(
3944
) -> AsyncGenerator[Result, None]:
4045
from mineru.cli.common import aio_do_parse # noqa: PLC0415
4146

42-
docs = list(docs)
43-
# TODO: exclude files which are not pdf and return an error
44-
pdfs_bytes = [d.path.read_bytes() for d in docs]
45-
pdfs_names = [d.path.name for d in docs]
46-
p_lang_list = [self._language for _ in pdfs_names]
47-
# TODO: we should only process valid PDFs
48-
with TemporaryDirectory(prefix="mineru-") as workdir:
49-
workdir = Path(workdir) # noqa: PLW2901
50-
await aio_do_parse(
51-
output_dir=workdir,
52-
pdf_file_names=pdfs_names,
53-
pdf_bytes_list=pdfs_bytes,
54-
p_lang_list=p_lang_list,
55-
**self._config.as_parse_kwargs(),
56-
)
57-
res_paths = [
58-
_revert_mineru_output(workdir, pdf_filename=p) for p in pdfs_names
59-
]
60-
for doc, res_path in zip(docs, res_paths, strict=True):
61-
yield _process_doc(
62-
doc,
63-
md_make_fn=self._md_make_fn,
64-
res_path=res_path,
65-
output_format=output_format,
66-
output_path=output_path,
47+
with reset_env():
48+
os.environ["MINERU_DEVICE_MODE"] = self._device
49+
docs = list(docs)
50+
# TODO: exclude files which are not pdf and return an error
51+
pdfs_bytes = [d.path.read_bytes() for d in docs]
52+
pdfs_names = [d.path.name for d in docs]
53+
p_lang_list = [self._language for _ in pdfs_names]
54+
# TODO: we should only process valid PDFs
55+
with TemporaryDirectory(prefix="mineru-") as workdir:
56+
workdir = Path(workdir) # noqa: PLW2901
57+
await aio_do_parse(
58+
output_dir=workdir,
59+
pdf_file_names=pdfs_names,
60+
pdf_bytes_list=pdfs_bytes,
61+
p_lang_list=p_lang_list,
62+
**self._config.as_parse_kwargs(),
6763
)
64+
res_paths = [
65+
_revert_mineru_output(workdir, pdf_filename=p) for p in pdfs_names
66+
]
67+
for doc, res_path in zip(docs, res_paths, strict=True):
68+
yield _process_doc(
69+
doc,
70+
md_make_fn=self._md_make_fn,
71+
res_path=res_path,
72+
output_format=output_format,
73+
output_path=output_path,
74+
)
6875

6976
@classmethod
70-
def _from_config(cls, config: MinerUPipelineConfig) -> Self:
71-
return cls(config.config, language=config.language)
77+
def _from_config(
78+
cls,
79+
config: MinerUPipelineConfig,
80+
*,
81+
device: Device = Device.CPU,
82+
) -> Self:
83+
return cls(config.config, language=config.language, device=device)
7284

7385

7486
def _revert_mineru_output(output_dir: Path, *, pdf_filename: str) -> Path:

‎extract-python/extract_python/utils.py‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import os
22
from collections.abc import Callable, Generator, Iterable, Iterator
33
from contextlib import contextmanager
4+
from copy import copy
45
from functools import wraps
56
from itertools import tee
67
from pathlib import Path, PurePath
@@ -62,3 +63,13 @@ def chdir(path: Path) -> Generator[None, None, None]:
6263
yield
6364
finally:
6465
os.chdir(cwd)
66+
67+
68+
@contextmanager
69+
def reset_env() -> Generator[None, None, None]:
70+
old_env = copy(dict(os.environ))
71+
try:
72+
yield
73+
finally:
74+
os.environ.clear()
75+
os.environ.update(old_env)

‎extract-python/tests/conftest.py‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,21 @@
11
import os
2+
import platform
23
import shutil
34
from pathlib import Path
45

56
import pytest
6-
from extract_core import InputDoc
7+
from extract_core import Device, InputDoc
78

89
from tests import TEST_DATA_DIR
910

1011

12+
@pytest.fixture(scope="session")
13+
def device() -> Device:
14+
if "darwin" in platform.system().lower():
15+
return Device.MPS
16+
return Device.CPU
17+
18+
1119
@pytest.fixture(scope="session")
1220
def docs() -> list[InputDoc]:
1321
doc_paths = ("scanned.pdf", "computer_generated.pdf")

0 commit comments

Comments
 (0)