From 283b597a6eee445f39f254469910caa8b91d3a8f Mon Sep 17 00:00:00 2001
From: icecraft
Date: Sat, 19 Oct 2024 17:12:16 +0800
Subject: [PATCH 1/3] feat: add [figure | table] match [caption | footnote]
match algorithm v2
feat: add Data api
---
.gitignore | 95 +-
.pre-commit-config.yaml | 5 +-
docs/en/api.rst | 9 +
docs/en/api/data_reader_writer.rst | 44 +
docs/en/api/dataset.rst | 27 +
docs/en/api/io.rst | 0
docs/en/api/read_api.rst | 6 +
docs/en/api/schemas.rst | 0
docs/en/api/utils.rst | 1 +
docs/en/index.rst | 12 +
magic_pdf/config/enums.py | 7 +
magic_pdf/config/exceptions.py | 32 +
magic_pdf/data/__init__.py | 0
magic_pdf/data/data_reader_writer/__init__.py | 12 +
magic_pdf/data/data_reader_writer/base.py | 51 +
magic_pdf/data/data_reader_writer/filebase.py | 59 +
.../data_reader_writer/multi_bucket_s3.py | 137 +
magic_pdf/data/data_reader_writer/s3.py | 69 +
magic_pdf/data/dataset.py | 194 +
magic_pdf/data/io/__init__.py | 0
magic_pdf/data/io/base.py | 42 +
magic_pdf/data/io/http.py | 37 +
magic_pdf/data/io/s3.py | 114 +
magic_pdf/data/read_api.py | 95 +
magic_pdf/data/schemas.py | 15 +
magic_pdf/data/utils.py | 32 +
magic_pdf/libs/config_reader.py | 47 +-
magic_pdf/libs/draw_bbox.py | 59 +-
magic_pdf/model/magic_model.py | 127 +-
magic_pdf/pdf_parse_by_ocr.py | 7 +-
magic_pdf/pdf_parse_by_txt.py | 7 +-
magic_pdf/pdf_parse_union_core_v2.py | 332 +-
magic_pdf/tools/common.py | 4 +-
magic_pdf/utils/annotations.py | 11 +
tests/test_data/__init__.py | 0
tests/test_data/assets/jsonl/test_01.jsonl | 1 +
tests/test_data/assets/jsonl/test_02.jsonl | 1 +
tests/test_data/assets/pdfs/test_01.pdf | Bin 0 -> 569248 bytes
tests/test_data/assets/pdfs/test_02.pdf | Bin 0 -> 578657 bytes
tests/test_data/assets/pngs/test_01.png | Bin 0 -> 334127 bytes
tests/test_data/assets/pngs/test_02.png | Bin 0 -> 420303 bytes
.../test_data/data_reader_writer/__init__.py | 0
.../data_reader_writer/test_filebase.py | 24 +
.../test_multi_bucket_s3.py | 82 +
tests/test_data/data_reader_writer/test_s3.py | 53 +
tests/test_data/io/__init__.py | 0
tests/test_data/io/test_s3.py | 55 +
tests/test_data/test_dataset.py | 18 +
tests/test_data/test_read_api.py | 78 +
tests/test_model/__init__.py | 0
tests/test_model/assets/test_01.model.json | 687 +
tests/test_model/assets/test_01.pdf | Bin 0 -> 70207 bytes
tests/test_model/assets/test_02.model.json | 17564 ++++++++++++++++
tests/test_model/assets/test_02.pdf | Bin 0 -> 336919 bytes
tests/test_model/test_magic_model.py | 31 +
55 files changed, 20028 insertions(+), 255 deletions(-)
create mode 100644 docs/en/api.rst
create mode 100644 docs/en/api/data_reader_writer.rst
create mode 100644 docs/en/api/dataset.rst
create mode 100644 docs/en/api/io.rst
create mode 100644 docs/en/api/read_api.rst
create mode 100644 docs/en/api/schemas.rst
create mode 100644 docs/en/api/utils.rst
create mode 100644 magic_pdf/config/enums.py
create mode 100644 magic_pdf/config/exceptions.py
create mode 100644 magic_pdf/data/__init__.py
create mode 100644 magic_pdf/data/data_reader_writer/__init__.py
create mode 100644 magic_pdf/data/data_reader_writer/base.py
create mode 100644 magic_pdf/data/data_reader_writer/filebase.py
create mode 100644 magic_pdf/data/data_reader_writer/multi_bucket_s3.py
create mode 100644 magic_pdf/data/data_reader_writer/s3.py
create mode 100644 magic_pdf/data/dataset.py
create mode 100644 magic_pdf/data/io/__init__.py
create mode 100644 magic_pdf/data/io/base.py
create mode 100644 magic_pdf/data/io/http.py
create mode 100644 magic_pdf/data/io/s3.py
create mode 100644 magic_pdf/data/read_api.py
create mode 100644 magic_pdf/data/schemas.py
create mode 100644 magic_pdf/data/utils.py
create mode 100644 magic_pdf/utils/annotations.py
create mode 100644 tests/test_data/__init__.py
create mode 100644 tests/test_data/assets/jsonl/test_01.jsonl
create mode 100644 tests/test_data/assets/jsonl/test_02.jsonl
create mode 100644 tests/test_data/assets/pdfs/test_01.pdf
create mode 100644 tests/test_data/assets/pdfs/test_02.pdf
create mode 100644 tests/test_data/assets/pngs/test_01.png
create mode 100644 tests/test_data/assets/pngs/test_02.png
create mode 100644 tests/test_data/data_reader_writer/__init__.py
create mode 100644 tests/test_data/data_reader_writer/test_filebase.py
create mode 100644 tests/test_data/data_reader_writer/test_multi_bucket_s3.py
create mode 100644 tests/test_data/data_reader_writer/test_s3.py
create mode 100644 tests/test_data/io/__init__.py
create mode 100644 tests/test_data/io/test_s3.py
create mode 100644 tests/test_data/test_dataset.py
create mode 100644 tests/test_data/test_read_api.py
create mode 100644 tests/test_model/__init__.py
create mode 100644 tests/test_model/assets/test_01.model.json
create mode 100644 tests/test_model/assets/test_01.pdf
create mode 100644 tests/test_model/assets/test_02.model.json
create mode 100644 tests/test_model/assets/test_02.pdf
create mode 100644 tests/test_model/test_magic_model.py
diff --git a/.gitignore b/.gitignore
index f86aeca2..b6ab4538 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,45 +1,50 @@
-*.tar
-*.tar.gz
-*.zip
-venv*/
-envs/
-slurm_logs/
-
-sync1.sh
-data_preprocess_pj1
-data-preparation1
-__pycache__
-*.log
-*.pyc
-.vscode
-debug/
-*.ipynb
-.idea
-
-# vscode history
-.history
-
-.DS_Store
-.env
-
-bad_words/
-bak/
-
-app/tests/*
-temp/
-tmp/
-tmp
-.vscode
-.vscode/
-ocr_demo
-.coveragerc
-/app/common/__init__.py
-/magic_pdf/config/__init__.py
-source.dev.env
-
-tmp
-
-projects/web/node_modules
-projects/web/dist
-
-projects/web_demo/web_demo/static/
+*.tar
+*.tar.gz
+*.zip
+venv*/
+envs/
+slurm_logs/
+
+sync1.sh
+data_preprocess_pj1
+data-preparation1
+__pycache__
+*.log
+*.pyc
+.vscode
+debug/
+*.ipynb
+.idea
+
+# vscode history
+.history
+
+.DS_Store
+.env
+
+bad_words/
+bak/
+
+app/tests/*
+temp/
+tmp/
+tmp
+.vscode
+.vscode/
+ocr_demo
+.coveragerc
+/app/common/__init__.py
+/magic_pdf/config/__init__.py
+source.dev.env
+
+tmp
+
+projects/web/node_modules
+projects/web/dist
+
+projects/web_demo/web_demo/static/
+cli_debug/
+debug_utils/
+
+# sphinx docs
+_build/
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index b86d6f8b..fc7446df 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -3,7 +3,7 @@ repos:
rev: 5.0.4
hooks:
- id: flake8
- args: ["--max-line-length=120", "--ignore=E131,E125,W503,W504,E203"]
+ args: ["--max-line-length=150", "--ignore=E131,E125,W503,W504,E203"]
- repo: https://github.com/PyCQA/isort
rev: 5.11.5
hooks:
@@ -12,11 +12,12 @@ repos:
rev: v0.32.0
hooks:
- id: yapf
- args: ["--style={based_on_style: google, column_limit: 120, indent_width: 4}"]
+ args: ["--style={based_on_style: google, column_limit: 150, indent_width: 4}"]
- repo: https://github.com/codespell-project/codespell
rev: v2.2.1
hooks:
- id: codespell
+ args: ['--skip', '*.json']
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.3.0
hooks:
diff --git a/docs/en/api.rst b/docs/en/api.rst
new file mode 100644
index 00000000..9c6a9e65
--- /dev/null
+++ b/docs/en/api.rst
@@ -0,0 +1,9 @@
+Data Api
+------------------
+
+.. toctree::
+ :maxdepth: 2
+
+ api/dataset.rst
+ api/data_reader_writer.rst
+ api/read_api.rst
diff --git a/docs/en/api/data_reader_writer.rst b/docs/en/api/data_reader_writer.rst
new file mode 100644
index 00000000..882c974c
--- /dev/null
+++ b/docs/en/api/data_reader_writer.rst
@@ -0,0 +1,44 @@
+
+Data Reader Writer
+--------------------
+
+.. autoclass:: magic_pdf.data.data_reader_writer.DataReader
+ :members:
+ :inherited-members:
+
+.. autoclass:: magic_pdf.data.data_reader_writer.DataWriter
+ :members:
+ :inherited-members:
+
+.. autoclass:: magic_pdf.data.data_reader_writer.S3DataReader
+ :members:
+ :inherited-members:
+
+.. autoclass:: magic_pdf.data.data_reader_writer.S3DataWriter
+ :members:
+ :inherited-members:
+
+.. autoclass:: magic_pdf.data.data_reader_writer.FileBasedDataReader
+ :members:
+ :inherited-members:
+
+.. autoclass:: magic_pdf.data.data_reader_writer.FileBasedDataWriter
+ :members:
+ :inherited-members:
+
+.. autoclass:: magic_pdf.data.data_reader_writer.S3DataReader
+ :members:
+ :inherited-members:
+
+.. autoclass:: magic_pdf.data.data_reader_writer.S3DataWriter
+ :members:
+ :inherited-members:
+
+.. autoclass:: magic_pdf.data.data_reader_writer.MultiBucketS3DataReader
+ :members:
+ :inherited-members:
+
+.. autoclass:: magic_pdf.data.data_reader_writer.MultiBucketS3DataWriter
+ :members:
+ :inherited-members:
+
diff --git a/docs/en/api/dataset.rst b/docs/en/api/dataset.rst
new file mode 100644
index 00000000..e2b9b76a
--- /dev/null
+++ b/docs/en/api/dataset.rst
@@ -0,0 +1,27 @@
+Dataset Api
+------------------
+
+.. autoclass:: magic_pdf.data.dataset.PageableData
+ :members:
+ :inherited-members:
+
+.. autoclass:: magic_pdf.data.dataset.Dataset
+ :members:
+ :inherited-members:
+
+.. autoclass:: magic_pdf.data.dataset.ImageDataset
+ :members:
+ :inherited-members:
+
+.. autoclass:: magic_pdf.data.dataset.PymuDocDataset
+ :members:
+ :inherited-members:
+
+.. autoclass:: magic_pdf.data.dataset.Image
+ :members:
+ :inherited-members:
+
+.. autoclass:: magic_pdf.data.dataset.Doc
+ :members:
+ :inherited-members:
+
diff --git a/docs/en/api/io.rst b/docs/en/api/io.rst
new file mode 100644
index 00000000..e69de29b
diff --git a/docs/en/api/read_api.rst b/docs/en/api/read_api.rst
new file mode 100644
index 00000000..f4ead0c6
--- /dev/null
+++ b/docs/en/api/read_api.rst
@@ -0,0 +1,6 @@
+read_api Api
+------------------
+
+.. automodule:: magic_pdf.data.dataset.read_api
+ :members:
+ :inherited-members:
diff --git a/docs/en/api/schemas.rst b/docs/en/api/schemas.rst
new file mode 100644
index 00000000..e69de29b
diff --git a/docs/en/api/utils.rst b/docs/en/api/utils.rst
new file mode 100644
index 00000000..8b137891
--- /dev/null
+++ b/docs/en/api/utils.rst
@@ -0,0 +1 @@
+
diff --git a/docs/en/index.rst b/docs/en/index.rst
index d275dde7..61a4bc39 100644
--- a/docs/en/index.rst
+++ b/docs/en/index.rst
@@ -24,3 +24,15 @@ Welcome to the MinerU Documentation
Watch
Fork
+
+
+API Reference
+-------------
+
+If you are looking for information on a specific function, class or
+method, this part of the documentation is for you.
+
+.. toctree::
+ :maxdepth: 2
+
+ api
diff --git a/magic_pdf/config/enums.py b/magic_pdf/config/enums.py
new file mode 100644
index 00000000..6f3e91a3
--- /dev/null
+++ b/magic_pdf/config/enums.py
@@ -0,0 +1,7 @@
+
+import enum
+
+
+class SupportedPdfParseMethod(enum.Enum):
+ OCR = 'ocr'
+ TXT = 'txt'
diff --git a/magic_pdf/config/exceptions.py b/magic_pdf/config/exceptions.py
new file mode 100644
index 00000000..38f326b5
--- /dev/null
+++ b/magic_pdf/config/exceptions.py
@@ -0,0 +1,32 @@
+
+class FileNotExisted(Exception):
+
+ def __init__(self, path):
+ self.path = path
+
+ def __str__(self):
+ return f'File {self.path} does not exist.'
+
+
+class InvalidConfig(Exception):
+ def __init__(self, msg):
+ self.msg = msg
+
+ def __str__(self):
+ return f'Invalid config: {self.msg}'
+
+
+class InvalidParams(Exception):
+ def __init__(self, msg):
+ self.msg = msg
+
+ def __str__(self):
+ return f'Invalid params: {self.msg}'
+
+
+class EmptyData(Exception):
+ def __init__(self, msg):
+ self.msg = msg
+
+ def __str__(self):
+ return f'Empty data: {self.msg}'
diff --git a/magic_pdf/data/__init__.py b/magic_pdf/data/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/magic_pdf/data/data_reader_writer/__init__.py b/magic_pdf/data/data_reader_writer/__init__.py
new file mode 100644
index 00000000..f8f82347
--- /dev/null
+++ b/magic_pdf/data/data_reader_writer/__init__.py
@@ -0,0 +1,12 @@
+from magic_pdf.data.data_reader_writer.filebase import \
+ FileBasedDataReader # noqa: F401
+from magic_pdf.data.data_reader_writer.filebase import \
+ FileBasedDataWriter # noqa: F401
+from magic_pdf.data.data_reader_writer.multi_bucket_s3 import \
+ MultiBucketS3DataReader # noqa: F401
+from magic_pdf.data.data_reader_writer.multi_bucket_s3 import \
+ MultiBucketS3DataWriter # noqa: F401
+from magic_pdf.data.data_reader_writer.s3 import S3DataReader # noqa: F401
+from magic_pdf.data.data_reader_writer.s3 import S3DataWriter # noqa: F401
+from magic_pdf.data.data_reader_writer.base import DataReader # noqa: F401
+from magic_pdf.data.data_reader_writer.base import DataWriter # noqa: F401
\ No newline at end of file
diff --git a/magic_pdf/data/data_reader_writer/base.py b/magic_pdf/data/data_reader_writer/base.py
new file mode 100644
index 00000000..7c9a8e8e
--- /dev/null
+++ b/magic_pdf/data/data_reader_writer/base.py
@@ -0,0 +1,51 @@
+
+from abc import ABC, abstractmethod
+
+
+class DataReader(ABC):
+
+ def read(self, path: str) -> bytes:
+ """Read the file.
+
+ Args:
+ path (str): file path to read
+
+ Returns:
+ bytes: the content of the file
+ """
+ return self.read_at(path)
+
+ @abstractmethod
+ def read_at(self, path: str, offset: int = 0, limit: int = -1) -> bytes:
+ """Read the file at offset and limit.
+
+ Args:
+ path (str): the file path
+ offset (int, optional): the number of bytes skipped. Defaults to 0.
+ limit (int, optional): the length of bytes want to read. Defaults to -1.
+
+ Returns:
+ bytes: the content of the file
+ """
+ pass
+
+
+class DataWriter(ABC):
+ @abstractmethod
+ def write(self, path: str, data: bytes) -> None:
+ """Write the data to the file.
+
+ Args:
+ path (str): the target file where to write
+ data (bytes): the data want to write
+ """
+ pass
+
+ def write_string(self, path: str, data: str) -> None:
+ """Write the data to file, the data will be encoded to bytes.
+
+ Args:
+ path (str): the target file where to write
+ data (str): the data want to write
+ """
+ self.write(path, data.encode())
diff --git a/magic_pdf/data/data_reader_writer/filebase.py b/magic_pdf/data/data_reader_writer/filebase.py
new file mode 100644
index 00000000..40e9e4ff
--- /dev/null
+++ b/magic_pdf/data/data_reader_writer/filebase.py
@@ -0,0 +1,59 @@
+import os
+
+from magic_pdf.data.data_reader_writer.base import DataReader, DataWriter
+
+
+class FileBasedDataReader(DataReader):
+ def __init__(self, parent_dir: str = ''):
+ """Initialized with parent_dir.
+
+ Args:
+ parent_dir (str, optional): the parent directory that may be used within methods. Defaults to ''.
+ """
+ self._parent_dir = parent_dir
+
+ def read_at(self, path: str, offset: int = 0, limit: int = -1) -> bytes:
+ """Read at offset and limit.
+
+ Args:
+ path (str): the path of file, if the path is relative path, it will be joined with parent_dir.
+ offset (int, optional): the number of bytes skipped. Defaults to 0.
+ limit (int, optional): the length of bytes want to read. Defaults to -1.
+
+ Returns:
+ bytes: the content of file
+ """
+ fn_path = path
+ if not os.path.isabs(fn_path) and len(self._parent_dir) > 0:
+ fn_path = os.path.join(self._parent_dir, path)
+
+ with open(fn_path, 'rb') as f:
+ f.seek(offset)
+ if limit == -1:
+ return f.read()
+ else:
+ return f.read(limit)
+
+
+class FileBasedDataWriter(DataWriter):
+ def __init__(self, parent_dir: str = '') -> None:
+ """Initialized with parent_dir.
+
+ Args:
+ parent_dir (str, optional): the parent directory that may be used within methods. Defaults to ''.
+ """
+ self._parent_dir = parent_dir
+
+ def write(self, path: str, data: bytes) -> None:
+ """Write file with data.
+
+ Args:
+ path (str): the path of file, if the path is relative path, it will be joined with parent_dir.
+ data (bytes): the data want to write
+ """
+ fn_path = path
+ if not os.path.isabs(fn_path) and len(self._parent_dir) > 0:
+ fn_path = os.path.join(self._parent_dir, path)
+
+ with open(fn_path, 'wb') as f:
+ f.write(data)
diff --git a/magic_pdf/data/data_reader_writer/multi_bucket_s3.py b/magic_pdf/data/data_reader_writer/multi_bucket_s3.py
new file mode 100644
index 00000000..4f6347b3
--- /dev/null
+++ b/magic_pdf/data/data_reader_writer/multi_bucket_s3.py
@@ -0,0 +1,137 @@
+from magic_pdf.config.exceptions import InvalidConfig, InvalidParams
+from magic_pdf.data.data_reader_writer.base import DataReader, DataWriter
+from magic_pdf.data.io.s3 import S3Reader, S3Writer
+from magic_pdf.data.schemas import S3Config
+from magic_pdf.libs.path_utils import (parse_s3_range_params, parse_s3path,
+ remove_non_official_s3_args)
+
+
+class MultiS3Mixin:
+ def __init__(self, default_bucket: str, s3_configs: list[S3Config]):
+ """Initialized with multiple s3 configs.
+
+ Args:
+ default_bucket (str): the default bucket name of the relative path
+ s3_configs (list[S3Config]): list of s3 configs, the bucket_name must be unique in the list.
+
+ Raises:
+ InvalidConfig: default bucket config not in s3_configs
+ InvalidConfig: bucket name not unique in s3_configs
+ InvalidConfig: default bucket must be provided
+ """
+ if len(default_bucket) == 0:
+ raise InvalidConfig('default_bucket must be provided')
+
+ found_default_bucket_config = False
+ for conf in s3_configs:
+ if conf.bucket_name == default_bucket:
+ found_default_bucket_config = True
+ break
+
+ if not found_default_bucket_config:
+ raise InvalidConfig(
+ f'default_bucket: {default_bucket} config must be provided in s3_configs: {s3_configs}'
+ )
+
+ uniq_bucket = set([conf.bucket_name for conf in s3_configs])
+ if len(uniq_bucket) != len(s3_configs):
+ raise InvalidConfig(
+ f'the bucket_name in s3_configs: {s3_configs} must be unique'
+ )
+
+ self.default_bucket = default_bucket
+ self.s3_configs = s3_configs
+ self._s3_clients_h: dict = {}
+
+
+class MultiBucketS3DataReader(DataReader, MultiS3Mixin):
+ def read(self, path: str) -> bytes:
+ """Read the path from s3, select diffect bucket client for each request
+ based on the path, also support range read.
+
+ Args:
+ path (str): the s3 path of file, the path must be in the format of s3://bucket_name/path?offset,limit
+ for example: s3://bucket_name/path?0,100
+
+ Returns:
+ bytes: the content of s3 file
+ """
+ may_range_params = parse_s3_range_params(path)
+ if may_range_params is None or 2 != len(may_range_params):
+ byte_start, byte_len = 0, -1
+ else:
+ byte_start, byte_len = int(may_range_params[0]), int(may_range_params[1])
+ path = remove_non_official_s3_args(path)
+ return self.read_at(path, byte_start, byte_len)
+
+ def __get_s3_client(self, bucket_name: str):
+ if bucket_name not in set([conf.bucket_name for conf in self.s3_configs]):
+ raise InvalidParams(
+ f'bucket name: {bucket_name} not found in s3_configs: {self.s3_configs}'
+ )
+ if bucket_name not in self._s3_clients_h:
+ conf = next(
+ filter(lambda conf: conf.bucket_name == bucket_name, self.s3_configs)
+ )
+ self._s3_clients_h[bucket_name] = S3Reader(
+ bucket_name,
+ conf.access_key,
+ conf.secret_key,
+ conf.endpoint_url,
+ conf.addressing_style,
+ )
+ return self._s3_clients_h[bucket_name]
+
+ def read_at(self, path: str, offset: int = 0, limit: int = -1) -> bytes:
+ """Read the file with offset and limit, select diffect bucket client
+ for each request based on the path.
+
+ Args:
+ path (str): the file path
+ offset (int, optional): the number of bytes skipped. Defaults to 0.
+ limit (int, optional): the number of bytes want to read. Defaults to -1 which means infinite.
+
+ Returns:
+ bytes: the file content
+ """
+ if path.startswith('s3://'):
+ bucket_name, path = parse_s3path(path)
+ s3_reader = self.__get_s3_client(bucket_name)
+ else:
+ s3_reader = self.__get_s3_client(self.default_bucket)
+ return s3_reader.read_at(path, offset, limit)
+
+
+class MultiBucketS3DataWriter(DataWriter, MultiS3Mixin):
+ def __get_s3_client(self, bucket_name: str):
+ if bucket_name not in set([conf.bucket_name for conf in self.s3_configs]):
+ raise InvalidParams(
+ f'bucket name: {bucket_name} not found in s3_configs: {self.s3_configs}'
+ )
+ if bucket_name not in self._s3_clients_h:
+ conf = next(
+ filter(lambda conf: conf.bucket_name == bucket_name, self.s3_configs)
+ )
+ self._s3_clients_h[bucket_name] = S3Writer(
+ bucket_name,
+ conf.access_key,
+ conf.secret_key,
+ conf.endpoint_url,
+ conf.addressing_style,
+ )
+ return self._s3_clients_h[bucket_name]
+
+ def write(self, path: str, data: bytes) -> None:
+ """Write file with data, also select diffect bucket client for each
+ request based on the path.
+
+ Args:
+ path (str): the path of file, if the path is relative path, it will be joined with parent_dir.
+ data (bytes): the data want to write
+ """
+ if path.startswith('s3://'):
+ bucket_name, path = parse_s3path(path)
+ s3_writer = self.__get_s3_client(bucket_name)
+ else:
+ s3_writer = self.__get_s3_client(self.default_bucket)
+ return s3_writer.write(path, data)
diff --git a/magic_pdf/data/data_reader_writer/s3.py b/magic_pdf/data/data_reader_writer/s3.py
new file mode 100644
index 00000000..b6f27a27
--- /dev/null
+++ b/magic_pdf/data/data_reader_writer/s3.py
@@ -0,0 +1,69 @@
+from magic_pdf.data.data_reader_writer.multi_bucket_s3 import (
+ MultiBucketS3DataReader, MultiBucketS3DataWriter)
+from magic_pdf.data.schemas import S3Config
+
+
+class S3DataReader(MultiBucketS3DataReader):
+ def __init__(
+ self,
+ bucket: str,
+ ak: str,
+ sk: str,
+ endpoint_url: str,
+ addressing_style: str = 'auto',
+ ):
+ """s3 reader client.
+
+ Args:
+ bucket (str): bucket name
+ ak (str): access key
+ sk (str): secret key
+ endpoint_url (str): endpoint url of s3
+ addressing_style (str, optional): Defaults to 'auto'. Other valid options here are 'path' and 'virtual'
+ refer to https://boto3.amazonaws.com/v1/documentation/api/1.9.42/guide/s3.html
+ """
+ super().__init__(
+ bucket,
+ [
+ S3Config(
+ bucket_name=bucket,
+ access_key=ak,
+ secret_key=sk,
+ endpoint_url=endpoint_url,
+ addressing_style=addressing_style,
+ )
+ ],
+ )
+
+
+class S3DataWriter(MultiBucketS3DataWriter):
+ def __init__(
+ self,
+ bucket: str,
+ ak: str,
+ sk: str,
+ endpoint_url: str,
+ addressing_style: str = 'auto',
+ ):
+ """s3 writer client.
+
+ Args:
+ bucket (str): bucket name
+ ak (str): access key
+ sk (str): secret key
+ endpoint_url (str): endpoint url of s3
+ addressing_style (str, optional): Defaults to 'auto'. Other valid options here are 'path' and 'virtual'
+ refer to https://boto3.amazonaws.com/v1/documentation/api/1.9.42/guide/s3.html
+ """
+ super().__init__(
+ bucket,
+ [
+ S3Config(
+ bucket_name=bucket,
+ access_key=ak,
+ secret_key=sk,
+ endpoint_url=endpoint_url,
+ addressing_style=addressing_style,
+ )
+ ],
+ )
diff --git a/magic_pdf/data/dataset.py b/magic_pdf/data/dataset.py
new file mode 100644
index 00000000..0eee3c68
--- /dev/null
+++ b/magic_pdf/data/dataset.py
@@ -0,0 +1,194 @@
+from abc import ABC, abstractmethod
+from typing import Iterator
+
+import fitz
+
+from magic_pdf.config.enums import SupportedPdfParseMethod
+from magic_pdf.data.schemas import PageInfo
+from magic_pdf.data.utils import fitz_doc_to_image
+
+
+class PageableData(ABC):
+ @abstractmethod
+ def get_image(self) -> dict:
+ """Transform data to image."""
+ pass
+
+ @abstractmethod
+ def get_doc(self) -> fitz.Page:
+ """Get the pymudoc page."""
+ pass
+
+ @abstractmethod
+ def get_page_info(self) -> PageInfo:
+ """Get the page info of the page.
+
+ Returns:
+ PageInfo: the page info of this page
+ """
+ pass
+
+
+class Dataset(ABC):
+ @abstractmethod
+ def __len__(self) -> int:
+ """The length of the dataset."""
+ pass
+
+ @abstractmethod
+ def __iter__(self) -> Iterator[PageableData]:
+ """Yield the page data."""
+ pass
+
+ @abstractmethod
+ def supported_methods(self) -> list[SupportedPdfParseMethod]:
+ """The methods that this dataset support.
+
+ Returns:
+ list[SupportedPdfParseMethod]: The supported methods, Valid methods are: OCR, TXT
+ """
+ pass
+
+ @abstractmethod
+ def data_bits(self) -> bytes:
+ """The bits used to create this dataset."""
+ pass
+
+ @abstractmethod
+ def get_page(self, page_id: int) -> PageableData:
+ """Get the page indexed by page_id.
+
+ Args:
+ page_id (int): the index of the page
+
+ Returns:
+ PageableData: the page doc object
+ """
+ pass
+
+
+class PymuDocDataset(Dataset):
+ def __init__(self, bits: bytes):
+ """Initialize the dataset, which wraps the pymudoc documents.
+
+ Args:
+ bits (bytes): the bytes of the pdf
+ """
+ self._records = [Doc(v) for v in fitz.open('pdf', bits)]
+ self._data_bits = bits
+ self._raw_data = bits
+
+ def __len__(self) -> int:
+ """The page number of the pdf."""
+ return len(self._records)
+
+ def __iter__(self) -> Iterator[PageableData]:
+ """Yield the page doc object."""
+ return iter(self._records)
+
+ def supported_methods(self) -> list[SupportedPdfParseMethod]:
+ """The method supported by this dataset.
+
+ Returns:
+ list[SupportedPdfParseMethod]: the supported methods
+ """
+ return [SupportedPdfParseMethod.OCR, SupportedPdfParseMethod.TXT]
+
+ def data_bits(self) -> bytes:
+ """The pdf bits used to create this dataset."""
+ return self._data_bits
+
+ def get_page(self, page_id: int) -> PageableData:
+ """The page doc object.
+
+ Args:
+ page_id (int): the page doc index
+
+ Returns:
+ PageableData: the page doc object
+ """
+ return self._records[page_id]
+
+
+class ImageDataset(Dataset):
+ def __init__(self, bits: bytes):
+ """Initialize the dataset, which wraps the pymudoc documents.
+
+ Args:
+ bits (bytes): the bytes of the photo which will be converted to pdf first. then converted to pymudoc.
+ """
+ pdf_bytes = fitz.open(stream=bits).convert_to_pdf()
+ self._records = [Doc(v) for v in fitz.open('pdf', pdf_bytes)]
+ self._raw_data = bits
+ self._data_bits = pdf_bytes
+
+ def __len__(self) -> int:
+ """The length of the dataset."""
+ return len(self._records)
+
+ def __iter__(self) -> Iterator[PageableData]:
+ """Yield the page object."""
+ return iter(self._records)
+
+ def supported_methods(self):
+ """The method supported by this dataset.
+
+ Returns:
+ list[SupportedPdfParseMethod]: the supported methods
+ """
+ return [SupportedPdfParseMethod.OCR]
+
+ def data_bits(self) -> bytes:
+ """The pdf bits used to create this dataset."""
+ return self._data_bits
+
+ def get_page(self, page_id: int) -> PageableData:
+ """The page doc object.
+
+ Args:
+ page_id (int): the page doc index
+
+ Returns:
+ PageableData: the page doc object
+ """
+ return self._records[page_id]
+
+
+class Doc(PageableData):
+ """Initialized with pymudoc object."""
+ def __init__(self, doc: fitz.Page):
+ self._doc = doc
+
+ def get_image(self):
+ """Return the imge info.
+
+ Returns:
+ dict: {
+ img: np.ndarray,
+ width: int,
+ height: int
+ }
+ """
+ return fitz_doc_to_image(self._doc)
+
+ def get_doc(self) -> fitz.Page:
+ """Get the pymudoc object.
+
+ Returns:
+ fitz.Page: the pymudoc object
+ """
+ return self._doc
+
+ def get_page_info(self) -> PageInfo:
+ """Get the page info of the page.
+
+ Returns:
+ PageInfo: the page info of this page
+ """
+ page_w = self._doc.rect.width
+ page_h = self._doc.rect.height
+ return PageInfo(w=page_w, h=page_h)
+
+ def __getattr__(self, name):
+ if hasattr(self._doc, name):
+ return getattr(self._doc, name)
diff --git a/magic_pdf/data/io/__init__.py b/magic_pdf/data/io/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/magic_pdf/data/io/base.py b/magic_pdf/data/io/base.py
new file mode 100644
index 00000000..c02c2ed4
--- /dev/null
+++ b/magic_pdf/data/io/base.py
@@ -0,0 +1,42 @@
+from abc import ABC, abstractmethod
+
+
+class IOReader(ABC):
+ @abstractmethod
+ def read(self, path: str) -> bytes:
+ """Read the file.
+
+ Args:
+ path (str): file path to read
+
+ Returns:
+ bytes: the content of the file
+ """
+ pass
+
+ @abstractmethod
+ def read_at(self, path: str, offset: int = 0, limit: int = -1) -> bytes:
+ """Read at offset and limit.
+
+ Args:
+ path (str): the path of file, if the path is relative path, it will be joined with parent_dir.
+ offset (int, optional): the number of bytes skipped. Defaults to 0.
+ limit (int, optional): the length of bytes want to read. Defaults to -1.
+
+ Returns:
+ bytes: the content of file
+ """
+ pass
+
+
+class IOWriter:
+
+ @abstractmethod
+ def write(self, path: str, data: bytes) -> None:
+ """Write file with data.
+
+ Args:
+ path (str): the path of file, if the path is relative path, it will be joined with parent_dir.
+ data (bytes): the data want to write
+ """
+ pass
diff --git a/magic_pdf/data/io/http.py b/magic_pdf/data/io/http.py
new file mode 100644
index 00000000..3b08271f
--- /dev/null
+++ b/magic_pdf/data/io/http.py
@@ -0,0 +1,37 @@
+
+import io
+
+import requests
+
+from magic_pdf.data.io.base import IOReader, IOWriter
+
+
+class HttpReader(IOReader):
+
+ def read(self, url: str) -> bytes:
+ """Read the file.
+
+ Args:
+ path (str): file path to read
+
+ Returns:
+ bytes: the content of the file
+ """
+ return requests.get(url).content
+
+ def read_at(self, path: str, offset: int = 0, limit: int = -1) -> bytes:
+ """Not Implemented."""
+ raise NotImplementedError
+
+
+class HttpWriter(IOWriter):
+ def write(self, url: str, data: bytes) -> None:
+ """Write file with data.
+
+ Args:
+ path (str): the path of file, if the path is relative path, it will be joined with parent_dir.
+ data (bytes): the data want to write
+ """
+ files = {'file': io.BytesIO(data)}
+ response = requests.post(url, files=files)
+ assert 300 > response.status_code and response.status_code > 199
diff --git a/magic_pdf/data/io/s3.py b/magic_pdf/data/io/s3.py
new file mode 100644
index 00000000..4222c73f
--- /dev/null
+++ b/magic_pdf/data/io/s3.py
@@ -0,0 +1,114 @@
+import boto3
+from botocore.config import Config
+
+from magic_pdf.data.io.base import IOReader, IOWriter
+
+
+class S3Reader(IOReader):
+ def __init__(
+ self,
+ bucket: str,
+ ak: str,
+ sk: str,
+ endpoint_url: str,
+ addressing_style: str = 'auto',
+ ):
+ """s3 reader client.
+
+ Args:
+ bucket (str): bucket name
+ ak (str): access key
+ sk (str): secret key
+ endpoint_url (str): endpoint url of s3
+ addressing_style (str, optional): Defaults to 'auto'. Other valid options here are 'path' and 'virtual'
+ refer to https://boto3.amazonaws.com/v1/documentation/api/1.9.42/guide/s3.html
+ """
+ self._bucket = bucket
+ self._ak = ak
+ self._sk = sk
+ self._s3_client = boto3.client(
+ service_name='s3',
+ aws_access_key_id=ak,
+ aws_secret_access_key=sk,
+ endpoint_url=endpoint_url,
+ config=Config(
+ s3={'addressing_style': addressing_style},
+ retries={'max_attempts': 5, 'mode': 'standard'},
+ ),
+ )
+
+ def read(self, key: str) -> bytes:
+ """Read the file.
+
+ Args:
+ path (str): file path to read
+
+ Returns:
+ bytes: the content of the file
+ """
+ return self.read_at(key)
+
+ def read_at(self, key: str, offset: int = 0, limit: int = -1) -> bytes:
+ """Read at offset and limit.
+
+ Args:
+ path (str): the path of file, if the path is relative path, it will be joined with parent_dir.
+ offset (int, optional): the number of bytes skipped. Defaults to 0.
+ limit (int, optional): the length of bytes want to read. Defaults to -1.
+
+ Returns:
+ bytes: the content of file
+ """
+ if limit > -1:
+ range_header = f'bytes={offset}-{offset+limit-1}'
+ res = self._s3_client.get_object(
+ Bucket=self._bucket, Key=key, Range=range_header
+ )
+ else:
+ res = self._s3_client.get_object(
+ Bucket=self._bucket, Key=key, Range=f'bytes={offset}-'
+ )
+ return res['Body'].read()
+
+
+class S3Writer(IOWriter):
+ def __init__(
+ self,
+ bucket: str,
+ ak: str,
+ sk: str,
+ endpoint_url: str,
+ addressing_style: str = 'auto',
+ ):
+ """s3 reader client.
+
+ Args:
+ bucket (str): bucket name
+ ak (str): access key
+ sk (str): secret key
+ endpoint_url (str): endpoint url of s3
+ addressing_style (str, optional): Defaults to 'auto'. Other valid options here are 'path' and 'virtual'
+ refer to https://boto3.amazonaws.com/v1/documentation/api/1.9.42/guide/s3.html
+ """
+ self._bucket = bucket
+ self._ak = ak
+ self._sk = sk
+ self._s3_client = boto3.client(
+ service_name='s3',
+ aws_access_key_id=ak,
+ aws_secret_access_key=sk,
+ endpoint_url=endpoint_url,
+ config=Config(
+ s3={'addressing_style': addressing_style},
+ retries={'max_attempts': 5, 'mode': 'standard'},
+ ),
+ )
+
+ def write(self, key: str, data: bytes):
+ """Write file with data.
+
+ Args:
+ path (str): the path of file, if the path is relative path, it will be joined with parent_dir.
+ data (bytes): the data want to write
+ """
+ self._s3_client.put_object(Bucket=self._bucket, Key=key, Body=data)
diff --git a/magic_pdf/data/read_api.py b/magic_pdf/data/read_api.py
new file mode 100644
index 00000000..61e0fbf7
--- /dev/null
+++ b/magic_pdf/data/read_api.py
@@ -0,0 +1,95 @@
+import json
+import os
+from pathlib import Path
+
+from magic_pdf.config.exceptions import EmptyData, InvalidParams
+from magic_pdf.data.data_reader_writer import (FileBasedDataReader,
+ MultiBucketS3DataReader)
+from magic_pdf.data.dataset import ImageDataset, PymuDocDataset
+
+
+def read_jsonl(
+ s3_path_or_local: str, s3_client: MultiBucketS3DataReader | None = None
+) -> list[PymuDocDataset]:
+ """Read the jsonl file and return the list of PymuDocDataset.
+
+ Args:
+ s3_path_or_local (str): local file or s3 path
+ s3_client (MultiBucketS3DataReader | None, optional): s3 client that support multiple bucket. Defaults to None.
+
+ Raises:
+ InvalidParams: if s3_path_or_local is s3 path but s3_client is not provided.
+ EmptyData: if no pdf file location is provided in some line of jsonl file.
+ InvalidParams: if the file location is s3 path but s3_client is not provided
+
+ Returns:
+ list[PymuDocDataset]: each line in the jsonl file will be converted to a PymuDocDataset
+ """
+ bits_arr = []
+ if s3_path_or_local.startswith('s3://'):
+ if s3_client is None:
+ raise InvalidParams('s3_client is required when s3_path is provided')
+ jsonl_bits = s3_client.read(s3_path_or_local)
+ else:
+ jsonl_bits = FileBasedDataReader('').read(s3_path_or_local)
+ jsonl_d = [
+ json.loads(line) for line in jsonl_bits.decode().split('\n') if line.strip()
+ ]
+ for d in jsonl_d[:5]:
+ pdf_path = d.get('file_location', '') or d.get('path', '')
+ if len(pdf_path) == 0:
+ raise EmptyData('pdf file location is empty')
+ if pdf_path.startswith('s3://'):
+ if s3_client is None:
+ raise InvalidParams('s3_client is required when s3_path is provided')
+ bits_arr.append(s3_client.read(pdf_path))
+ else:
+ bits_arr.append(FileBasedDataReader('').read(pdf_path))
+ return [PymuDocDataset(bits) for bits in bits_arr]
+
+
+def read_local_pdfs(path: str) -> list[PymuDocDataset]:
+ """Read pdf from path or directory.
+
+ Args:
+ path (str): pdf file path or directory that contains pdf files
+
+ Returns:
+ list[PymuDocDataset]: each pdf file will converted to a PymuDocDataset
+ """
+ if os.path.isdir(path):
+ reader = FileBasedDataReader(path)
+ return [
+ PymuDocDataset(reader.read(doc_path.name))
+ for doc_path in Path(path).glob('*.pdf')
+ ]
+ else:
+ reader = FileBasedDataReader()
+ bits = reader.read(path)
+ return [PymuDocDataset(bits)]
+
+
+def read_local_images(path: str, suffixes: list[str]) -> list[ImageDataset]:
+ """Read images from path or directory.
+
+ Args:
+ path (str): image file path or directory that contains image files
+ suffixes (list[str]): the suffixes of the image files used to filter the files. Example: ['jpg', 'png']
+
+ Returns:
+ list[ImageDataset]: each image file will converted to a ImageDataset
+ """
+ if os.path.isdir(path):
+ imgs_bits = []
+ s_suffixes = set(suffixes)
+ reader = FileBasedDataReader(path)
+ for root, _, files in os.walk(path):
+ for file in files:
+ suffix = file.split('.')
+ if suffix[-1] in s_suffixes:
+ imgs_bits.append(reader.read(file))
+ return [ImageDataset(bits) for bits in imgs_bits]
+ else:
+ reader = FileBasedDataReader()
+ bits = reader.read(path)
+ return [ImageDataset(bits)]
diff --git a/magic_pdf/data/schemas.py b/magic_pdf/data/schemas.py
new file mode 100644
index 00000000..3adb6760
--- /dev/null
+++ b/magic_pdf/data/schemas.py
@@ -0,0 +1,15 @@
+
+from pydantic import BaseModel, Field
+
+
+class S3Config(BaseModel):
+ bucket_name: str = Field(description='s3 bucket name', min_length=1)
+ access_key: str = Field(description='s3 access key', min_length=1)
+ secret_key: str = Field(description='s3 secret key', min_length=1)
+ endpoint_url: str = Field(description='s3 endpoint url', min_length=1)
+ addressing_style: str = Field(description='s3 addressing style', default='auto', min_length=1)
+
+
+class PageInfo(BaseModel):
+ w: float = Field(description='the width of page')
+ h: float = Field(description='the height of page')
diff --git a/magic_pdf/data/utils.py b/magic_pdf/data/utils.py
new file mode 100644
index 00000000..dfe7abde
--- /dev/null
+++ b/magic_pdf/data/utils.py
@@ -0,0 +1,32 @@
+
+import fitz
+import numpy as np
+
+from magic_pdf.utils.annotations import ImportPIL
+
+
+@ImportPIL
+def fitz_doc_to_image(doc, dpi=200) -> dict:
+ """Convert fitz.Document to image, Then convert the image to numpy array.
+
+ Args:
+ doc (_type_): pymudoc page
+ dpi (int, optional): reset the dpi of dpi. Defaults to 200.
+
+ Returns:
+ dict: {'img': numpy array, 'width': width, 'height': height }
+ """
+ from PIL import Image
+ mat = fitz.Matrix(dpi / 72, dpi / 72)
+ pm = doc.get_pixmap(matrix=mat, alpha=False)
+
+ # If the width or height exceeds 9000 after scaling, do not scale further.
+ if pm.width > 9000 or pm.height > 9000:
+ pm = doc.get_pixmap(matrix=fitz.Matrix(1, 1), alpha=False)
+
+ img = Image.frombytes('RGB', (pm.width, pm.height), pm.samples)
+ img = np.array(img)
+
+ img_dict = {'img': img, 'width': pm.width, 'height': pm.height}
+
+ return img_dict
diff --git a/magic_pdf/libs/config_reader.py b/magic_pdf/libs/config_reader.py
index 8a831d7f..5e1a300d 100644
--- a/magic_pdf/libs/config_reader.py
+++ b/magic_pdf/libs/config_reader.py
@@ -1,7 +1,4 @@
-"""
-根据bucket的名字返回对应的s3 AK, SK,endpoint三元组
-
-"""
+"""根据bucket的名字返回对应的s3 AK, SK,endpoint三元组."""
import json
import os
@@ -12,36 +9,36 @@
from magic_pdf.libs.commons import parse_bucket_key
# 定义配置文件名常量
-CONFIG_FILE_NAME = "magic-pdf.json"
+CONFIG_FILE_NAME = os.getenv('MINERU_TOOLS_CONFIG_JSON', 'magic-pdf.json')
def read_config():
- home_dir = os.path.expanduser("~")
-
- config_file = os.path.join(home_dir, CONFIG_FILE_NAME)
+ if os.path.isabs(CONFIG_FILE_NAME):
+ config_file = CONFIG_FILE_NAME
+ else:
+ home_dir = os.path.expanduser('~')
+ config_file = os.path.join(home_dir, CONFIG_FILE_NAME)
if not os.path.exists(config_file):
- raise FileNotFoundError(f"{config_file} not found")
+ raise FileNotFoundError(f'{config_file} not found')
- with open(config_file, "r", encoding="utf-8") as f:
+ with open(config_file, 'r', encoding='utf-8') as f:
config = json.load(f)
return config
def get_s3_config(bucket_name: str):
- """
- ~/magic-pdf.json 读出来
- """
+ """~/magic-pdf.json 读出来."""
config = read_config()
- bucket_info = config.get("bucket_info")
+ bucket_info = config.get('bucket_info')
if bucket_name not in bucket_info:
- access_key, secret_key, storage_endpoint = bucket_info["[default]"]
+ access_key, secret_key, storage_endpoint = bucket_info['[default]']
else:
access_key, secret_key, storage_endpoint = bucket_info[bucket_name]
if access_key is None or secret_key is None or storage_endpoint is None:
- raise Exception(f"ak, sk or endpoint not found in {CONFIG_FILE_NAME}")
+ raise Exception(f'ak, sk or endpoint not found in {CONFIG_FILE_NAME}')
# logger.info(f"get_s3_config: ak={access_key}, sk={secret_key}, endpoint={storage_endpoint}")
@@ -50,7 +47,7 @@ def get_s3_config(bucket_name: str):
def get_s3_config_dict(path: str):
access_key, secret_key, storage_endpoint = get_s3_config(get_bucket_name(path))
- return {"ak": access_key, "sk": secret_key, "endpoint": storage_endpoint}
+ return {'ak': access_key, 'sk': secret_key, 'endpoint': storage_endpoint}
def get_bucket_name(path):
@@ -60,20 +57,20 @@ def get_bucket_name(path):
def get_local_models_dir():
config = read_config()
- models_dir = config.get("models-dir")
+ models_dir = config.get('models-dir')
if models_dir is None:
logger.warning(f"'models-dir' not found in {CONFIG_FILE_NAME}, use '/tmp/models' as default")
- return "/tmp/models"
+ return '/tmp/models'
else:
return models_dir
def get_local_layoutreader_model_dir():
config = read_config()
- layoutreader_model_dir = config.get("layoutreader-model-dir")
+ layoutreader_model_dir = config.get('layoutreader-model-dir')
if layoutreader_model_dir is None or not os.path.exists(layoutreader_model_dir):
- home_dir = os.path.expanduser("~")
- layoutreader_at_modelscope_dir_path = os.path.join(home_dir, ".cache/modelscope/hub/ppaanngggg/layoutreader")
+ home_dir = os.path.expanduser('~')
+ layoutreader_at_modelscope_dir_path = os.path.join(home_dir, '.cache/modelscope/hub/ppaanngggg/layoutreader')
logger.warning(f"'layoutreader-model-dir' not exists, use {layoutreader_at_modelscope_dir_path} as default")
return layoutreader_at_modelscope_dir_path
else:
@@ -82,17 +79,17 @@ def get_local_layoutreader_model_dir():
def get_device():
config = read_config()
- device = config.get("device-mode")
+ device = config.get('device-mode')
if device is None:
logger.warning(f"'device-mode' not found in {CONFIG_FILE_NAME}, use 'cpu' as default")
- return "cpu"
+ return 'cpu'
else:
return device
def get_table_recog_config():
config = read_config()
- table_config = config.get("table-config")
+ table_config = config.get('table-config')
if table_config is None:
logger.warning(f"'table-config' not found in {CONFIG_FILE_NAME}, use 'False' as default")
return json.loads(f'{{"model": "{MODEL_NAME.TABLE_MASTER}","enable": false, "max_time": 400}}')
diff --git a/magic_pdf/libs/draw_bbox.py b/magic_pdf/libs/draw_bbox.py
index 225280f1..e8916f4e 100644
--- a/magic_pdf/libs/draw_bbox.py
+++ b/magic_pdf/libs/draw_bbox.py
@@ -1,3 +1,4 @@
+from magic_pdf.data.dataset import PymuDocDataset
from magic_pdf.libs.commons import fitz # PyMuPDF
from magic_pdf.libs.Constants import CROSS_PAGE
from magic_pdf.libs.ocr_content_type import BlockType, CategoryId, ContentType
@@ -62,7 +63,7 @@ def draw_bbox_with_number(i, bbox_list, page, rgb_config, fill_config, draw_bbox
overlay=True,
) # Draw the rectangle
page.insert_text(
- (x1+2, y0 + 10), str(j + 1), fontsize=10, color=new_rgb
+ (x1 + 2, y0 + 10), str(j + 1), fontsize=10, color=new_rgb
) # Insert the index in the top left corner of the rectangle
@@ -86,7 +87,7 @@ def draw_layout_bbox(pdf_info, pdf_bytes, out_path, filename):
texts = []
interequations = []
lists = []
- indexs = []
+ indices = []
for dropped_bbox in page['discarded_blocks']:
page_dropped_list.append(dropped_bbox['bbox'])
@@ -122,7 +123,7 @@ def draw_layout_bbox(pdf_info, pdf_bytes, out_path, filename):
elif block['type'] == BlockType.List:
lists.append(bbox)
elif block['type'] == BlockType.Index:
- indexs.append(bbox)
+ indices.append(bbox)
tables_list.append(tables)
tables_body_list.append(tables_body)
@@ -136,7 +137,7 @@ def draw_layout_bbox(pdf_info, pdf_bytes, out_path, filename):
texts_list.append(texts)
interequations_list.append(interequations)
lists_list.append(lists)
- indexs_list.append(indexs)
+ indexs_list.append(indices)
layout_bbox_list = []
@@ -151,30 +152,24 @@ def draw_layout_bbox(pdf_info, pdf_bytes, out_path, filename):
for i, page in enumerate(pdf_docs):
- draw_bbox_without_number(i, dropped_bbox_list, page, [158, 158, 158],
- True)
- draw_bbox_without_number(i, tables_list, page, [153, 153, 0],
- True) # color !
- draw_bbox_without_number(i, tables_body_list, page, [204, 204, 0],
- True)
- draw_bbox_without_number(i, tables_caption_list, page, [255, 255, 102],
- True)
- draw_bbox_without_number(i, tables_footnote_list, page,
- [229, 255, 204], True)
+ draw_bbox_without_number(i, dropped_bbox_list, page, [158, 158, 158], True)
+ draw_bbox_without_number(i, tables_list, page, [153, 153, 0], True) # color !
+ draw_bbox_without_number(i, tables_body_list, page, [204, 204, 0], True)
+ draw_bbox_without_number(i, tables_caption_list, page, [255, 255, 102], True)
+ draw_bbox_without_number(i, tables_footnote_list, page, [229, 255, 204], True)
draw_bbox_without_number(i, imgs_list, page, [51, 102, 0], True)
draw_bbox_without_number(i, imgs_body_list, page, [153, 255, 51], True)
- draw_bbox_without_number(i, imgs_caption_list, page, [102, 178, 255],
- True)
- draw_bbox_without_number(i, imgs_footnote_list, page, [255, 178, 102],
- True),
+ draw_bbox_without_number(i, imgs_caption_list, page, [102, 178, 255], True)
+ draw_bbox_without_number(i, imgs_footnote_list, page, [255, 178, 102], True),
draw_bbox_without_number(i, titles_list, page, [102, 102, 255], True)
draw_bbox_without_number(i, texts_list, page, [153, 0, 76], True)
- draw_bbox_without_number(i, interequations_list, page, [0, 255, 0],
- True)
+ draw_bbox_without_number(i, interequations_list, page, [0, 255, 0], True)
draw_bbox_without_number(i, lists_list, page, [40, 169, 92], True)
draw_bbox_without_number(i, indexs_list, page, [40, 169, 92], True)
- draw_bbox_with_number(i, layout_bbox_list, page, [255, 0, 0], False, draw_bbox=False)
+ draw_bbox_with_number(
+ i, layout_bbox_list, page, [255, 0, 0], False, draw_bbox=False
+ )
# Save the PDF
pdf_docs.save(f'{out_path}/{filename}_layout.pdf')
@@ -275,7 +270,7 @@ def draw_model_bbox(model_list: list, pdf_bytes, out_path, filename):
texts_list = []
interequations_list = []
pdf_docs = fitz.open('pdf', pdf_bytes)
- magic_model = MagicModel(model_list, pdf_docs)
+ magic_model = MagicModel(model_list, PymuDocDataset(pdf_bytes))
for i in range(len(model_list)):
page_dropped_list = []
tables_body, tables_caption, tables_footnote = [], [], []
@@ -301,8 +296,7 @@ def draw_model_bbox(model_list: list, pdf_bytes, out_path, filename):
imgs_body.append(bbox)
elif layout_det['category_id'] == CategoryId.ImageCaption:
imgs_caption.append(bbox)
- elif layout_det[
- 'category_id'] == CategoryId.InterlineEquation_YOLO:
+ elif layout_det['category_id'] == CategoryId.InterlineEquation_YOLO:
interequations.append(bbox)
elif layout_det['category_id'] == CategoryId.Abandon:
page_dropped_list.append(bbox)
@@ -321,18 +315,15 @@ def draw_model_bbox(model_list: list, pdf_bytes, out_path, filename):
imgs_footnote_list.append(imgs_footnote)
for i, page in enumerate(pdf_docs):
- draw_bbox_with_number(i, dropped_bbox_list, page, [158, 158, 158],
- True) # color !
+ draw_bbox_with_number(
+ i, dropped_bbox_list, page, [158, 158, 158], True
+ ) # color !
draw_bbox_with_number(i, tables_body_list, page, [204, 204, 0], True)
- draw_bbox_with_number(i, tables_caption_list, page, [255, 255, 102],
- True)
- draw_bbox_with_number(i, tables_footnote_list, page, [229, 255, 204],
- True)
+ draw_bbox_with_number(i, tables_caption_list, page, [255, 255, 102], True)
+ draw_bbox_with_number(i, tables_footnote_list, page, [229, 255, 204], True)
draw_bbox_with_number(i, imgs_body_list, page, [153, 255, 51], True)
- draw_bbox_with_number(i, imgs_caption_list, page, [102, 178, 255],
- True)
- draw_bbox_with_number(i, imgs_footnote_list, page, [255, 178, 102],
- True)
+ draw_bbox_with_number(i, imgs_caption_list, page, [102, 178, 255], True)
+ draw_bbox_with_number(i, imgs_footnote_list, page, [255, 178, 102], True)
draw_bbox_with_number(i, titles_list, page, [102, 102, 255], True)
draw_bbox_with_number(i, texts_list, page, [153, 0, 76], True)
draw_bbox_with_number(i, interequations_list, page, [0, 255, 0], True)
diff --git a/magic_pdf/model/magic_model.py b/magic_pdf/model/magic_model.py
index 79feecae..0e29174f 100644
--- a/magic_pdf/model/magic_model.py
+++ b/magic_pdf/model/magic_model.py
@@ -1,5 +1,6 @@
import json
+from magic_pdf.data.dataset import Dataset
from magic_pdf.libs.boxbase import (_is_in, _is_part_overlap, bbox_distance,
bbox_relative_pos, box_area, calculate_iou,
calculate_overlap_area_in_bbox1_area_ratio,
@@ -24,7 +25,7 @@ def __fix_axis(self):
need_remove_list = []
page_no = model_page_info['page_info']['page_no']
horizontal_scale_ratio, vertical_scale_ratio = get_scale_ratio(
- model_page_info, self.__docs[page_no]
+ model_page_info, self.__docs.get_page(page_no)
)
layout_dets = model_page_info['layout_dets']
for layout_det in layout_dets:
@@ -99,7 +100,7 @@ def __fix_by_remove_high_iou_and_low_confidence(self):
for need_remove in need_remove_list:
layout_dets.remove(need_remove)
- def __init__(self, model_list: list, docs: fitz.Document):
+ def __init__(self, model_list: list, docs: Dataset):
self.__model_list = model_list
self.__docs = docs
"""为所有模型数据添加bbox信息(缩放,poly->bbox)"""
@@ -123,7 +124,8 @@ def _bbox_distance(self, bbox1, bbox2):
l1 = bbox1[2] - bbox1[0]
l2 = bbox2[2] - bbox2[0]
- if l2 > l1 and (l2 - l1) / l1 > 0.5:
+ min_l, max_l = min(l1, l2), max(l1, l2)
+ if (max_l - min_l) * 1.0 / max_l > 0.4:
return float('inf')
return bbox_distance(bbox1, bbox2)
@@ -213,9 +215,8 @@ def __tie_up_category_by_distance(
筛选出所有和 merged bbox 有 overlap 且 overlap 面积大于 object 的面积的 subjects。
再求出筛选出的 subjects 和 object 的最短距离
"""
- def search_overlap_between_boxes(
- subject_idx, object_idx
- ):
+
+ def search_overlap_between_boxes(subject_idx, object_idx):
idxes = [subject_idx, object_idx]
x0s = [all_bboxes[idx]['bbox'][0] for idx in idxes]
y0s = [all_bboxes[idx]['bbox'][1] for idx in idxes]
@@ -243,9 +244,9 @@ def search_overlap_between_boxes(
for other_object in other_objects:
ratio = max(
ratio,
- get_overlap_area(
- merged_bbox, other_object['bbox']
- ) * 1.0 / box_area(all_bboxes[object_idx]['bbox'])
+ get_overlap_area(merged_bbox, other_object['bbox'])
+ * 1.0
+ / box_area(all_bboxes[object_idx]['bbox']),
)
if ratio >= MERGE_BOX_OVERLAP_AREA_RATIO:
break
@@ -363,12 +364,17 @@ def expand_bbbox(idxes):
if all_bboxes[j]['category_id'] == subject_category_id:
subject_idx, object_idx = j, i
- if search_overlap_between_boxes(subject_idx, object_idx) >= MERGE_BOX_OVERLAP_AREA_RATIO:
+ if (
+ search_overlap_between_boxes(subject_idx, object_idx)
+ >= MERGE_BOX_OVERLAP_AREA_RATIO
+ ):
dis[i][j] = float('inf')
dis[j][i] = dis[i][j]
continue
- dis[i][j] = self._bbox_distance(all_bboxes[subject_idx]['bbox'], all_bboxes[object_idx]['bbox'])
+ dis[i][j] = self._bbox_distance(
+ all_bboxes[subject_idx]['bbox'], all_bboxes[object_idx]['bbox']
+ )
dis[j][i] = dis[i][j]
used = set()
@@ -584,6 +590,99 @@ def expand_bbbox(idxes):
with_caption_subject.add(j)
return ret, total_subject_object_dis
+ def __tie_up_category_by_distance_v2(
+ self, page_no, subject_category_id, object_category_id
+ ):
+
+ subjects = self.__reduct_overlap(
+ list(
+ map(
+ lambda x: {'bbox': x['bbox'], 'score': x['score']},
+ filter(
+ lambda x: x['category_id'] == subject_category_id,
+ self.__model_list[page_no]['layout_dets'],
+ ),
+ )
+ )
+ )
+
+ objects = self.__reduct_overlap(
+ list(
+ map(
+ lambda x: {'bbox': x['bbox'], 'score': x['score']},
+ filter(
+ lambda x: x['category_id'] == object_category_id,
+ self.__model_list[page_no]['layout_dets'],
+ ),
+ )
+ )
+ )
+
+ print(len(subjects), len(objects))
+
+ subjects.sort(key=lambda x: x['bbox'][0] ** 2 + x['bbox'][1] ** 2)
+ objects.sort(key=lambda x: x['bbox'][0] ** 2 + x['bbox'][1] ** 2)
+ dis = [[float('inf')] * len(subjects) for _ in range(len(objects))]
+ for i, obj in enumerate(objects):
+ for j, sub in enumerate(subjects):
+ dis[i][j] = self._bbox_distance(sub['bbox'], obj['bbox'])
+
+ sub_obj_map_h = {i: [] for i in range(len(subjects))}
+ for i in range(len(objects)):
+ min_l_idx = 0
+ for j in range(1, len(subjects)):
+ if dis[i][j] == float('inf'):
+ continue
+ if dis[i][j] < dis[i][min_l_idx]:
+ min_l_idx = j
+
+ if dis[i][min_l_idx] < float('inf'):
+ sub_obj_map_h[min_l_idx].append(i)
+ else:
+ print(i, 'no nearest')
+ ret = []
+ for i in sub_obj_map_h.keys():
+ ret.append(
+ {
+ 'sub_bbox': subjects[i]['bbox'],
+ 'obj_bboxes': [objects[j]['bbox'] for j in sub_obj_map_h[i]],
+ 'sub_idx': i,
+ }
+ )
+ return ret
+
+ def get_imgs_v2(self, page_no: int):
+ with_captions = self.__tie_up_category_by_distance_v2(page_no, 3, 4)
+ with_footnotes = self.__tie_up_category_by_distance_v2(
+ page_no, 3, CategoryId.ImageFootnote
+ )
+ ret = []
+ for v in with_captions:
+ record = {
+ 'image_bbox': v['sub_bbox'],
+ 'image_caption_bbox_list': v['obj_bboxes'],
+ }
+ filter_idx = v['sub_idx']
+ d = next(filter(lambda x: x['sub_idx'] == filter_idx, with_footnotes))
+ record['image_footnote_bbox_list'] = d['obj_bboxes']
+ ret.append(record)
+ return ret
+
+ def get_tables_v2(self, page_no: int) -> list:
+ with_captions = self.__tie_up_category_by_distance_v2(page_no, 5, 6)
+ with_footnotes = self.__tie_up_category_by_distance_v2(page_no, 5, 7)
+ ret = []
+ for v in with_captions:
+ record = {
+ 'table_bbox': v['sub_bbox'],
+ 'table_caption_bbox_list': v['obj_bboxes'],
+ }
+ filter_idx = v['sub_idx']
+ d = next(filter(lambda x: x['sub_idx'] == filter_idx, with_footnotes))
+ record['table_footnote_bbox_list'] = d['obj_bboxes']
+ ret.append(record)
+ return ret
+
def get_imgs(self, page_no: int):
with_captions, _ = self.__tie_up_category_by_distance(page_no, 3, 4)
with_footnotes, _ = self.__tie_up_category_by_distance(
@@ -717,10 +816,10 @@ def remove_duplicate_spans(spans):
def get_page_size(self, page_no: int): # 获取页面宽高
# 获取当前页的page对象
- page = self.__docs[page_no]
+ page = self.__docs.get_page(page_no).get_page_info()
# 获取当前页的宽高
- page_w = page.rect.width
- page_h = page.rect.height
+ page_w = page.w
+ page_h = page.h
return page_w, page_h
def __get_blocks_by_type(
diff --git a/magic_pdf/pdf_parse_by_ocr.py b/magic_pdf/pdf_parse_by_ocr.py
index 0686d59e..ca2f394b 100644
--- a/magic_pdf/pdf_parse_by_ocr.py
+++ b/magic_pdf/pdf_parse_by_ocr.py
@@ -1,3 +1,5 @@
+from magic_pdf.config.enums import SupportedPdfParseMethod
+from magic_pdf.data.dataset import PymuDocDataset
from magic_pdf.pdf_parse_union_core_v2 import pdf_parse_union
@@ -8,10 +10,11 @@ def parse_pdf_by_ocr(pdf_bytes,
end_page_id=None,
debug_mode=False,
):
- return pdf_parse_union(pdf_bytes,
+ dataset = PymuDocDataset(pdf_bytes)
+ return pdf_parse_union(dataset,
model_list,
imageWriter,
- "ocr",
+ SupportedPdfParseMethod.OCR,
start_page_id=start_page_id,
end_page_id=end_page_id,
debug_mode=debug_mode,
diff --git a/magic_pdf/pdf_parse_by_txt.py b/magic_pdf/pdf_parse_by_txt.py
index bd8e202d..bae800f4 100644
--- a/magic_pdf/pdf_parse_by_txt.py
+++ b/magic_pdf/pdf_parse_by_txt.py
@@ -1,3 +1,5 @@
+from magic_pdf.config.enums import SupportedPdfParseMethod
+from magic_pdf.data.dataset import PymuDocDataset
from magic_pdf.pdf_parse_union_core_v2 import pdf_parse_union
@@ -9,10 +11,11 @@ def parse_pdf_by_txt(
end_page_id=None,
debug_mode=False,
):
- return pdf_parse_union(pdf_bytes,
+ dataset = PymuDocDataset(pdf_bytes)
+ return pdf_parse_union(dataset,
model_list,
imageWriter,
- "txt",
+ SupportedPdfParseMethod.TXT,
start_page_id=start_page_id,
end_page_id=end_page_id,
debug_mode=debug_mode,
diff --git a/magic_pdf/pdf_parse_union_core_v2.py b/magic_pdf/pdf_parse_union_core_v2.py
index 7f01bd50..92bcfc09 100644
--- a/magic_pdf/pdf_parse_union_core_v2.py
+++ b/magic_pdf/pdf_parse_union_core_v2.py
@@ -1,13 +1,13 @@
import os
import statistics
import time
-
-from loguru import logger
-
from typing import List
import torch
+from loguru import logger
+from magic_pdf.config.enums import SupportedPdfParseMethod
+from magic_pdf.data.dataset import Dataset, PageableData
from magic_pdf.libs.clean_memory import clean_memory
from magic_pdf.libs.commons import fitz, get_delta_time
from magic_pdf.libs.config_reader import get_local_layoutreader_model_dir
@@ -19,27 +19,35 @@
from magic_pdf.model.magic_model import MagicModel
from magic_pdf.para.para_split_v3 import para_split
from magic_pdf.pre_proc.citationmarker_remove import remove_citation_marker
-from magic_pdf.pre_proc.construct_page_dict import ocr_construct_page_component_v2
+from magic_pdf.pre_proc.construct_page_dict import \
+ ocr_construct_page_component_v2
from magic_pdf.pre_proc.cut_image import ocr_cut_image_and_table
-from magic_pdf.pre_proc.equations_replace import remove_chars_in_text_blocks, replace_equations_in_textblock, \
- combine_chars_to_pymudict
-from magic_pdf.pre_proc.ocr_detect_all_bboxes import ocr_prepare_bboxes_for_layout_split_v2
-from magic_pdf.pre_proc.ocr_dict_merge import fill_spans_in_blocks, fix_block_spans, fix_discarded_block
-from magic_pdf.pre_proc.ocr_span_list_modify import remove_overlaps_min_spans, get_qa_need_list_v2, \
- remove_overlaps_low_confidence_spans
-from magic_pdf.pre_proc.resolve_bbox_conflict import check_useful_block_horizontal_overlap
+from magic_pdf.pre_proc.equations_replace import (
+ combine_chars_to_pymudict, remove_chars_in_text_blocks,
+ replace_equations_in_textblock)
+from magic_pdf.pre_proc.ocr_detect_all_bboxes import \
+ ocr_prepare_bboxes_for_layout_split_v2
+from magic_pdf.pre_proc.ocr_dict_merge import (fill_spans_in_blocks,
+ fix_block_spans,
+ fix_discarded_block)
+from magic_pdf.pre_proc.ocr_span_list_modify import (
+ get_qa_need_list_v2, remove_overlaps_low_confidence_spans,
+ remove_overlaps_min_spans)
+from magic_pdf.pre_proc.resolve_bbox_conflict import \
+ check_useful_block_horizontal_overlap
def remove_horizontal_overlap_block_which_smaller(all_bboxes):
useful_blocks = []
for bbox in all_bboxes:
- useful_blocks.append({
- "bbox": bbox[:4]
- })
- is_useful_block_horz_overlap, smaller_bbox, bigger_bbox = check_useful_block_horizontal_overlap(useful_blocks)
+ useful_blocks.append({'bbox': bbox[:4]})
+ is_useful_block_horz_overlap, smaller_bbox, bigger_bbox = (
+ check_useful_block_horizontal_overlap(useful_blocks)
+ )
if is_useful_block_horz_overlap:
logger.warning(
- f"skip this page, reason: {DropReason.USEFUL_BLOCK_HOR_OVERLAP}, smaller bbox is {smaller_bbox}, bigger bbox is {bigger_bbox}")
+ f'skip this page, reason: {DropReason.USEFUL_BLOCK_HOR_OVERLAP}, smaller bbox is {smaller_bbox}, bigger bbox is {bigger_bbox}'
+ ) # noqa: E501
for bbox in all_bboxes.copy():
if smaller_bbox == bbox[:4]:
all_bboxes.remove(bbox)
@@ -47,27 +55,27 @@ def remove_horizontal_overlap_block_which_smaller(all_bboxes):
return is_useful_block_horz_overlap, all_bboxes
-def __replace_STX_ETX(text_str:str):
- """ Replace \u0002 and \u0003, as these characters become garbled when extracted using pymupdf. In fact, they were originally quotation marks.
-Drawback: This issue is only observed in English text; it has not been found in Chinese text so far.
+def __replace_STX_ETX(text_str: str):
+ """Replace \u0002 and \u0003, as these characters become garbled when extracted using pymupdf. In fact, they were originally quotation marks.
+ Drawback: This issue is only observed in English text; it has not been found in Chinese text so far.
- Args:
- text_str (str): raw text
+ Args:
+ text_str (str): raw text
- Returns:
- _type_: replaced text
- """
+ Returns:
+ _type_: replaced text
+ """ # noqa: E501
if text_str:
s = text_str.replace('\u0002', "'")
- s = s.replace("\u0003", "'")
+ s = s.replace('\u0003', "'")
return s
return text_str
def txt_spans_extract(pdf_page, inline_equations, interline_equations):
- text_raw_blocks = pdf_page.get_text("dict", flags=fitz.TEXTFLAGS_TEXT)["blocks"]
- char_level_text_blocks = pdf_page.get_text("rawdict", flags=fitz.TEXTFLAGS_TEXT)[
- "blocks"
+ text_raw_blocks = pdf_page.get_text('dict', flags=fitz.TEXTFLAGS_TEXT)['blocks']
+ char_level_text_blocks = pdf_page.get_text('rawdict', flags=fitz.TEXTFLAGS_TEXT)[
+ 'blocks'
]
text_blocks = combine_chars_to_pymudict(text_raw_blocks, char_level_text_blocks)
text_blocks = replace_equations_in_textblock(
@@ -77,54 +85,63 @@ def txt_spans_extract(pdf_page, inline_equations, interline_equations):
text_blocks = remove_chars_in_text_blocks(text_blocks)
spans = []
for v in text_blocks:
- for line in v["lines"]:
- for span in line["spans"]:
- bbox = span["bbox"]
+ for line in v['lines']:
+ for span in line['spans']:
+ bbox = span['bbox']
if float_equal(bbox[0], bbox[2]) or float_equal(bbox[1], bbox[3]):
continue
- if span.get('type') not in (ContentType.InlineEquation, ContentType.InterlineEquation):
+ if span.get('type') not in (
+ ContentType.InlineEquation,
+ ContentType.InterlineEquation,
+ ):
spans.append(
{
- "bbox": list(span["bbox"]),
- "content": __replace_STX_ETX(span["text"]),
- "type": ContentType.Text,
- "score": 1.0,
+ 'bbox': list(span['bbox']),
+ 'content': __replace_STX_ETX(span['text']),
+ 'type': ContentType.Text,
+ 'score': 1.0,
}
)
return spans
def replace_text_span(pymu_spans, ocr_spans):
- return list(filter(lambda x: x["type"] != ContentType.Text, ocr_spans)) + pymu_spans
+ return list(filter(lambda x: x['type'] != ContentType.Text, ocr_spans)) + pymu_spans
def model_init(model_name: str):
from transformers import LayoutLMv3ForTokenClassification
+
if torch.cuda.is_available():
- device = torch.device("cuda")
+ device = torch.device('cuda')
if torch.cuda.is_bf16_supported():
supports_bfloat16 = True
else:
supports_bfloat16 = False
else:
- device = torch.device("cpu")
+ device = torch.device('cpu')
supports_bfloat16 = False
- if model_name == "layoutreader":
+ if model_name == 'layoutreader':
# 检测modelscope的缓存目录是否存在
layoutreader_model_dir = get_local_layoutreader_model_dir()
if os.path.exists(layoutreader_model_dir):
- model = LayoutLMv3ForTokenClassification.from_pretrained(layoutreader_model_dir)
+ model = LayoutLMv3ForTokenClassification.from_pretrained(
+ layoutreader_model_dir
+ )
else:
logger.warning(
- f"local layoutreader model not exists, use online model from huggingface")
- model = LayoutLMv3ForTokenClassification.from_pretrained("hantian/layoutreader")
+ 'local layoutreader model not exists, use online model from huggingface'
+ )
+ model = LayoutLMv3ForTokenClassification.from_pretrained(
+ 'hantian/layoutreader'
+ )
# 检查设备是否支持 bfloat16
if supports_bfloat16:
model.bfloat16()
model.to(device).eval()
else:
- logger.error("model name not allow")
+ logger.error('model name not allow')
exit(1)
return model
@@ -145,7 +162,9 @@ def get_model(self, model_name: str):
def do_predict(boxes: List[List[int]], model) -> List[int]:
- from magic_pdf.model.v3.helpers import prepare_inputs, boxes2inputs, parse_logits
+ from magic_pdf.model.v3.helpers import (boxes2inputs, parse_logits,
+ prepare_inputs)
+
inputs = boxes2inputs(boxes)
inputs = prepare_inputs(inputs, model)
logits = model(**inputs).logits.cpu().squeeze(0)
@@ -193,21 +212,23 @@ def insert_lines_into_block(block_bbox, line_height, page_w, page_h):
block_weight = x1 - x0
# 如果block高度小于n行正文,则直接返回block的bbox
- if line_height*3 < block_height:
- if block_height > page_h*0.25 and page_w*0.5 > block_weight > page_w*0.25: # 可能是双列结构,可以切细点
- lines = int(block_height/line_height)+1
+ if line_height * 3 < block_height:
+ if (
+ block_height > page_h * 0.25 and page_w * 0.5 > block_weight > page_w * 0.25
+ ): # 可能是双列结构,可以切细点
+ lines = int(block_height / line_height) + 1
else:
# 如果block的宽度超过0.4页面宽度,则将block分成3行
- if block_weight > page_w*0.4:
+ if block_weight > page_w * 0.4:
line_height = (y1 - y0) / 3
lines = 3
- elif block_weight > page_w*0.25: # 否则将block分成两行
+ elif block_weight > page_w * 0.25: # 否则将block分成两行
line_height = (y1 - y0) / 2
lines = 2
- else: # 判断长宽比
- if block_height/block_weight > 1.2: # 细长的不分
+ else: # 判断长宽比
+ if block_height / block_weight > 1.2: # 细长的不分
return [[x0, y0, x1, y1]]
- else: # 不细长的还是分成两行
+ else: # 不细长的还是分成两行
line_height = (y1 - y0) / 2
lines = 2
@@ -256,19 +277,23 @@ def sort_lines_by_model(fix_blocks, page_w, page_h, line_height):
for left, top, right, bottom in page_line_list:
if left < 0:
logger.warning(
- f"left < 0, left: {left}, right: {right}, top: {top}, bottom: {bottom}, page_w: {page_w}, page_h: {page_h}")
+ f'left < 0, left: {left}, right: {right}, top: {top}, bottom: {bottom}, page_w: {page_w}, page_h: {page_h}'
+ ) # noqa: E501
left = 0
if right > page_w:
logger.warning(
- f"right > page_w, left: {left}, right: {right}, top: {top}, bottom: {bottom}, page_w: {page_w}, page_h: {page_h}")
+ f'right > page_w, left: {left}, right: {right}, top: {top}, bottom: {bottom}, page_w: {page_w}, page_h: {page_h}'
+ ) # noqa: E501
right = page_w
if top < 0:
logger.warning(
- f"top < 0, left: {left}, right: {right}, top: {top}, bottom: {bottom}, page_w: {page_w}, page_h: {page_h}")
+ f'top < 0, left: {left}, right: {right}, top: {top}, bottom: {bottom}, page_w: {page_w}, page_h: {page_h}'
+ ) # noqa: E501
top = 0
if bottom > page_h:
logger.warning(
- f"bottom > page_h, left: {left}, right: {right}, top: {top}, bottom: {bottom}, page_w: {page_w}, page_h: {page_h}")
+ f'bottom > page_h, left: {left}, right: {right}, top: {top}, bottom: {bottom}, page_w: {page_w}, page_h: {page_h}'
+ ) # noqa: E501
bottom = page_h
left = round(left * x_scale)
@@ -276,11 +301,11 @@ def sort_lines_by_model(fix_blocks, page_w, page_h, line_height):
right = round(right * x_scale)
bottom = round(bottom * y_scale)
assert (
- 1000 >= right >= left >= 0 and 1000 >= bottom >= top >= 0
- ), f"Invalid box. right: {right}, left: {left}, bottom: {bottom}, top: {top}"
+ 1000 >= right >= left >= 0 and 1000 >= bottom >= top >= 0
+ ), f'Invalid box. right: {right}, left: {left}, bottom: {bottom}, top: {top}' # noqa: E126, E121
boxes.append([left, top, right, bottom])
model_manager = ModelSingleton()
- model = model_manager.get_model("layoutreader")
+ model = model_manager.get_model('layoutreader')
with torch.no_grad():
orders = do_predict(boxes, model)
sorted_bboxes = [page_line_list[i] for i in orders]
@@ -294,146 +319,195 @@ def get_line_height(blocks):
if block['type'] in ['text', 'title', 'interline_equation']:
for line in block['lines']:
bbox = line['bbox']
- page_line_height_list.append(int(bbox[3]-bbox[1]))
+ page_line_height_list.append(int(bbox[3] - bbox[1]))
if len(page_line_height_list) > 0:
return statistics.median(page_line_height_list)
else:
return 10
-def parse_page_core(pdf_docs, magic_model, page_id, pdf_bytes_md5, imageWriter, parse_mode):
+def parse_page_core(
+ page_doc: PageableData, magic_model, page_id, pdf_bytes_md5, imageWriter, parse_mode
+):
need_drop = False
drop_reason = []
- '''从magic_model对象中获取后面会用到的区块信息'''
+ """从magic_model对象中获取后面会用到的区块信息"""
img_blocks = magic_model.get_imgs(page_id)
table_blocks = magic_model.get_tables(page_id)
discarded_blocks = magic_model.get_discarded(page_id)
text_blocks = magic_model.get_text_blocks(page_id)
title_blocks = magic_model.get_title_blocks(page_id)
- inline_equations, interline_equations, interline_equation_blocks = magic_model.get_equations(page_id)
+ inline_equations, interline_equations, interline_equation_blocks = (
+ magic_model.get_equations(page_id)
+ )
page_w, page_h = magic_model.get_page_size(page_id)
spans = magic_model.get_all_spans(page_id)
- '''根据parse_mode,构造spans'''
- if parse_mode == "txt":
+ """根据parse_mode,构造spans"""
+ if parse_mode == SupportedPdfParseMethod.TXT:
"""ocr 中文本类的 span 用 pymu spans 替换!"""
- pymu_spans = txt_spans_extract(
- pdf_docs[page_id], inline_equations, interline_equations
- )
+ pymu_spans = txt_spans_extract(page_doc, inline_equations, interline_equations)
spans = replace_text_span(pymu_spans, spans)
- elif parse_mode == "ocr":
+ elif parse_mode == SupportedPdfParseMethod.OCR:
pass
else:
- raise Exception("parse_mode must be txt or ocr")
+ raise Exception('parse_mode must be txt or ocr')
- '''删除重叠spans中置信度较低的那些'''
+ """删除重叠spans中置信度较低的那些"""
spans, dropped_spans_by_confidence = remove_overlaps_low_confidence_spans(spans)
- '''删除重叠spans中较小的那些'''
+ """删除重叠spans中较小的那些"""
spans, dropped_spans_by_span_overlap = remove_overlaps_min_spans(spans)
- '''对image和table截图'''
- spans = ocr_cut_image_and_table(spans, pdf_docs[page_id], page_id, pdf_bytes_md5, imageWriter)
+ """对image和table截图"""
+ spans = ocr_cut_image_and_table(
+ spans, page_doc, page_id, pdf_bytes_md5, imageWriter
+ )
- '''将所有区块的bbox整理到一起'''
+ """将所有区块的bbox整理到一起"""
# interline_equation_blocks参数不够准,后面切换到interline_equations上
interline_equation_blocks = []
if len(interline_equation_blocks) > 0:
all_bboxes, all_discarded_blocks = ocr_prepare_bboxes_for_layout_split_v2(
- img_blocks, table_blocks, discarded_blocks, text_blocks, title_blocks,
- interline_equation_blocks, page_w, page_h)
+ img_blocks,
+ table_blocks,
+ discarded_blocks,
+ text_blocks,
+ title_blocks,
+ interline_equation_blocks,
+ page_w,
+ page_h,
+ )
else:
all_bboxes, all_discarded_blocks = ocr_prepare_bboxes_for_layout_split_v2(
- img_blocks, table_blocks, discarded_blocks, text_blocks, title_blocks,
- interline_equations, page_w, page_h)
+ img_blocks,
+ table_blocks,
+ discarded_blocks,
+ text_blocks,
+ title_blocks,
+ interline_equations,
+ page_w,
+ page_h,
+ )
- '''先处理不需要排版的discarded_blocks'''
- discarded_block_with_spans, spans = fill_spans_in_blocks(all_discarded_blocks, spans, 0.4)
+ """先处理不需要排版的discarded_blocks"""
+ discarded_block_with_spans, spans = fill_spans_in_blocks(
+ all_discarded_blocks, spans, 0.4
+ )
fix_discarded_blocks = fix_discarded_block(discarded_block_with_spans)
- '''如果当前页面没有bbox则跳过'''
+ """如果当前页面没有bbox则跳过"""
if len(all_bboxes) == 0:
- logger.warning(f"skip this page, not found useful bbox, page_id: {page_id}")
- return ocr_construct_page_component_v2([], [], page_id, page_w, page_h, [],
- [], [], interline_equations, fix_discarded_blocks,
- need_drop, drop_reason)
+ logger.warning(f'skip this page, not found useful bbox, page_id: {page_id}')
+ return ocr_construct_page_component_v2(
+ [],
+ [],
+ page_id,
+ page_w,
+ page_h,
+ [],
+ [],
+ [],
+ interline_equations,
+ fix_discarded_blocks,
+ need_drop,
+ drop_reason,
+ )
- '''将span填入blocks中'''
+ """将span填入blocks中"""
block_with_spans, spans = fill_spans_in_blocks(all_bboxes, spans, 0.5)
- '''对block进行fix操作'''
+ """对block进行fix操作"""
fix_blocks = fix_block_spans(block_with_spans, img_blocks, table_blocks)
- '''获取所有line并计算正文line的高度'''
+ """获取所有line并计算正文line的高度"""
line_height = get_line_height(fix_blocks)
- '''获取所有line并对line排序'''
+ """获取所有line并对line排序"""
sorted_bboxes = sort_lines_by_model(fix_blocks, page_w, page_h, line_height)
- '''根据line的中位数算block的序列关系'''
+ """根据line的中位数算block的序列关系"""
fix_blocks = cal_block_index(fix_blocks, sorted_bboxes)
- '''重排block'''
+ """重排block"""
sorted_blocks = sorted(fix_blocks, key=lambda b: b['index'])
- '''获取QA需要外置的list'''
+ """获取QA需要外置的list"""
images, tables, interline_equations = get_qa_need_list_v2(sorted_blocks)
- '''构造pdf_info_dict'''
- page_info = ocr_construct_page_component_v2(sorted_blocks, [], page_id, page_w, page_h, [],
- images, tables, interline_equations, fix_discarded_blocks,
- need_drop, drop_reason)
+ """构造pdf_info_dict"""
+ page_info = ocr_construct_page_component_v2(
+ sorted_blocks,
+ [],
+ page_id,
+ page_w,
+ page_h,
+ [],
+ images,
+ tables,
+ interline_equations,
+ fix_discarded_blocks,
+ need_drop,
+ drop_reason,
+ )
return page_info
-def pdf_parse_union(pdf_bytes,
- model_list,
- imageWriter,
- parse_mode,
- start_page_id=0,
- end_page_id=None,
- debug_mode=False,
- ):
- pdf_bytes_md5 = compute_md5(pdf_bytes)
- pdf_docs = fitz.open("pdf", pdf_bytes)
+def pdf_parse_union(
+ dataset: Dataset,
+ model_list,
+ imageWriter,
+ parse_mode,
+ start_page_id=0,
+ end_page_id=None,
+ debug_mode=False,
+):
+ pdf_bytes_md5 = compute_md5(dataset.data_bits())
- '''初始化空的pdf_info_dict'''
+ """初始化空的pdf_info_dict"""
pdf_info_dict = {}
- '''用model_list和docs对象初始化magic_model'''
- magic_model = MagicModel(model_list, pdf_docs)
+ """用model_list和docs对象初始化magic_model"""
+ magic_model = MagicModel(model_list, dataset)
- '''根据输入的起始范围解析pdf'''
+ """根据输入的起始范围解析pdf"""
# end_page_id = end_page_id if end_page_id else len(pdf_docs) - 1
- end_page_id = end_page_id if end_page_id is not None and end_page_id >= 0 else len(pdf_docs) - 1
+ end_page_id = (
+ end_page_id
+ if end_page_id is not None and end_page_id >= 0
+ else len(dataset) - 1
+ )
- if end_page_id > len(pdf_docs) - 1:
- logger.warning("end_page_id is out of range, use pdf_docs length")
- end_page_id = len(pdf_docs) - 1
+ if end_page_id > len(dataset) - 1:
+ logger.warning('end_page_id is out of range, use pdf_docs length')
+ end_page_id = len(dataset) - 1
- '''初始化启动时间'''
+ """初始化启动时间"""
start_time = time.time()
- for page_id, page in enumerate(pdf_docs):
- '''debug时输出每页解析的耗时'''
+ for page_id, page in enumerate(dataset):
+ """debug时输出每页解析的耗时."""
if debug_mode:
time_now = time.time()
logger.info(
- f"page_id: {page_id}, last_page_cost_time: {get_delta_time(start_time)}"
+ f'page_id: {page_id}, last_page_cost_time: {get_delta_time(start_time)}'
)
start_time = time_now
- '''解析pdf中的每一页'''
+ """解析pdf中的每一页"""
if start_page_id <= page_id <= end_page_id:
- page_info = parse_page_core(pdf_docs, magic_model, page_id, pdf_bytes_md5, imageWriter, parse_mode)
+ page_info = parse_page_core(
+ page, magic_model, page_id, pdf_bytes_md5, imageWriter, parse_mode
+ )
else:
- page_w = page.rect.width
- page_h = page.rect.height
- page_info = ocr_construct_page_component_v2([], [], page_id, page_w, page_h, [],
- [], [], [], [],
- True, "skip page")
- pdf_info_dict[f"page_{page_id}"] = page_info
+ page_info = page.get_page_info()
+ page_w = page_info.w
+ page_h = page_info.h
+ page_info = ocr_construct_page_component_v2(
+ [], [], page_id, page_w, page_h, [], [], [], [], [], True, 'skip page'
+ )
+ pdf_info_dict[f'page_{page_id}'] = page_info
"""分段"""
para_split(pdf_info_dict, debug_mode=debug_mode)
@@ -441,7 +515,7 @@ def pdf_parse_union(pdf_bytes,
"""dict转list"""
pdf_info_list = dict_to_list(pdf_info_dict)
new_pdf_info_dict = {
- "pdf_info": pdf_info_list,
+ 'pdf_info': pdf_info_list,
}
clean_memory()
diff --git a/magic_pdf/tools/common.py b/magic_pdf/tools/common.py
index ba0a740d..c98da4c3 100644
--- a/magic_pdf/tools/common.py
+++ b/magic_pdf/tools/common.py
@@ -6,8 +6,8 @@
from loguru import logger
import magic_pdf.model as model_config
-from magic_pdf.libs.draw_bbox import (draw_layout_bbox, draw_span_bbox,
- draw_model_bbox, draw_line_sort_bbox)
+from magic_pdf.libs.draw_bbox import (draw_layout_bbox, draw_line_sort_bbox,
+ draw_model_bbox, draw_span_bbox)
from magic_pdf.libs.MakeContentConfig import DropMode, MakeMode
from magic_pdf.pipe.OCRPipe import OCRPipe
from magic_pdf.pipe.TXTPipe import TXTPipe
diff --git a/magic_pdf/utils/annotations.py b/magic_pdf/utils/annotations.py
new file mode 100644
index 00000000..898d8803
--- /dev/null
+++ b/magic_pdf/utils/annotations.py
@@ -0,0 +1,11 @@
+
+from loguru import logger
+
+
+def ImportPIL(f):
+ try:
+ import PIL # noqa: F401
+ except ImportError:
+ logger.error('Pillow not installed, please install by pip.')
+ exit(1)
+ return f
diff --git a/tests/test_data/__init__.py b/tests/test_data/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/tests/test_data/assets/jsonl/test_01.jsonl b/tests/test_data/assets/jsonl/test_01.jsonl
new file mode 100644
index 00000000..e3bfabb4
--- /dev/null
+++ b/tests/test_data/assets/jsonl/test_01.jsonl
@@ -0,0 +1 @@
+{"track_id":"e8824f5a-9fcb-4ee5-b2d4-6bf2c67019dc","path":"s3://sci-hub/enbook-scimag/78800000/libgen.scimag78872000-78872999/10.1017/cbo9780511770425.012.pdf","file_type":"pdf","content_type":"application/pdf","content_length":80078,"title":"German Idealism and the Concept of Punishment || Conclusion","remark":{"file_id":"scihub_78800000/libgen.scimag78872000-78872999.zip_10.1017/cbo9780511770425.012","file_source_type":"paper","original_file_id":"10.1017/cbo9780511770425.012","file_name":"10.1017/cbo9780511770425.012.pdf","author":"Merle, Jean-Christophe"}}
diff --git a/tests/test_data/assets/jsonl/test_02.jsonl b/tests/test_data/assets/jsonl/test_02.jsonl
new file mode 100644
index 00000000..cbed8675
--- /dev/null
+++ b/tests/test_data/assets/jsonl/test_02.jsonl
@@ -0,0 +1 @@
+{"track_id":"e8824f5a-9fcb-4ee5-b2d4-6bf2c67019dc","path":"tests/test_data/assets/pdfs/test_02.pdf","file_type":"pdf","content_type":"application/pdf","content_length":80078,"title":"German Idealism and the Concept of Punishment || Conclusion","remark":{"file_id":"scihub_78800000/libgen.scimag78872000-78872999.zip_10.1017/cbo9780511770425.012","file_source_type":"paper","original_file_id":"10.1017/cbo9780511770425.012","file_name":"10.1017/cbo9780511770425.012.pdf","author":"Merle, Jean-Christophe"}}
diff --git a/tests/test_data/assets/pdfs/test_01.pdf b/tests/test_data/assets/pdfs/test_01.pdf
new file mode 100644
index 0000000000000000000000000000000000000000..229be9cee2d9877185033789c6d72217cd8d1271
GIT binary patch
literal 569248
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 sr#S{Fs0fm-0aq^AcJAcYJ+(~
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
|