-
Notifications
You must be signed in to change notification settings - Fork 60
Feature/cpu optimizations #562
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: develop
Are you sure you want to change the base?
Changes from all commits
f02ac22
6423caf
a27e079
a881528
2eeddc6
035c72a
da8ec2c
02a34e5
88b54cb
1b4759d
a1ddcf8
40511f2
e59296b
6aa5837
a355dd5
1d9fa06
221f27a
d91fda2
b0d845d
57b3cca
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -11,7 +11,7 @@ | |
| from dedoc.readers.pdf_reader.pdf_auto_reader.txtlayer_classifier.abstract_txtlayer_classifier import AbstractTxtlayerClassifier | ||
| from dedoc.readers.pdf_reader.pdf_auto_reader.txtlayer_result import TxtLayerResult | ||
| from dedoc.readers.pdf_reader.pdf_txtlayer_reader.pdf_tabby_reader import PdfTabbyReader | ||
| from dedoc.utils.parameter_utils import get_bool_parameter, get_param_page_slice | ||
| from dedoc.utils.parameter_utils import get_bool_parameter, get_param_page_slice, get_param_pdf_with_txt_layer, get_param_with_attachments | ||
| from dedoc.utils.pdf_utils import get_pdf_page_count | ||
|
|
||
|
|
||
|
|
@@ -56,22 +56,60 @@ def __classify_all_pages( | |
| Separately handle the first page (it's common that only first page doesn't have a textual layer). | ||
| """ | ||
| parameters_copy = deepcopy(parameters) | ||
| parameters_copy["pages"] = "1:8" # two batches for pdf_txtlayer_reader | ||
| parameters_copy["need_pdf_table_analysis"] = "false" | ||
| # When the whole document already fits inside the 8-page detection window, extract it once with the *full* | ||
| # parameters (all pages + tables, matching __parse_document's own read) and hand the result to | ||
| # __parse_document via TxtLayerResult.document -- this avoids launching a second tabby/Java subprocess to | ||
| # re-extract the same pages, ~halving the wall for small text-layer documents (common in prod). Larger | ||
| # documents keep the cheap first-8-pages-only, no-tables detection. | ||
| page_count = get_pdf_page_count(path) | ||
| reusable = page_count is not None and page_count <= 8 and start == 1 and end is None | ||
| # For longer documents the detection window cannot replace the whole read, but its extraction is still not | ||
| # wasted: tabby's raw per-page output is handed to PdfTabbyReader, which then extracts only the pages the | ||
| # detection did not cover. This is free -- the tabby reader ignores need_pdf_table_analysis, so this read | ||
| # already produces a complete extraction of those pages, which used to be thrown away. Restricted to: | ||
| # * auto_tabby -- under "auto" the document is read by pdf_txtlayer_reader, which cannot consume tabby's pages; | ||
| # * runs without attachments -- extracted image files live in this read's temporary directory, which is gone | ||
| # by the time the second read would reference them. | ||
| pages_reusable = ( | ||
| not reusable | ||
| and start == 1 # noqa W503 | ||
| and get_param_pdf_with_txt_layer(parameters) == "auto_tabby" # noqa W503 | ||
| and not get_param_with_attachments(parameters) # noqa W503 | ||
| ) | ||
| detected_pages = [] if pages_reusable else None | ||
| if reusable: | ||
| parameters_copy["pages"] = "1:" # exactly __parse_document's slice for a whole-document request; tables kept on | ||
| else: | ||
| parameters_copy["pages"] = "1:8" # two batches for pdf_txtlayer_reader | ||
| parameters_copy["need_pdf_table_analysis"] = "false" | ||
| if pages_reusable: | ||
| parameters_copy["__tabby_raw_pages_out"] = detected_pages | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Мне не нравится этот способ передачи данных между tabby и детектором текстового слоя из-за его неявности. Еще мне не нравится то, что в tabby мы зашиваем логику от детектора текстового слоя, tabby о ней не должен знать. Я бы сейчас не стала оптимизировать конкретно эту проблему (когда мы читаем первые 8 страниц tabby и не хотим их потом читать снова), потому что в будущем это решение всё равно поменяем, и это не кажется мне очень большой проблемой сейчас. Единственное, что кажется можно сделать попроще - не читать второй раз короткие документы (<8 страниц) |
||
|
|
||
| document = self.pdf_reader.read(path, parameters=parameters_copy) | ||
| reuse_document = document if reusable else None | ||
| detected_last_page = len(detected_pages) if detected_pages else 0 | ||
| is_correct = txtlayer_classifier.predict([document.lines])[0] | ||
| if not is_correct: | ||
| return [TxtLayerResult(correct=False, start=start, end=end)] | ||
|
|
||
| if start > 1: # no need to classify correctness of the first page | ||
| return [TxtLayerResult(correct=True, start=start, end=end)] | ||
| return [TxtLayerResult(correct=True, start=start, end=end, document=reuse_document)] | ||
|
|
||
| first_page_lines = [line for line in document.lines if line.metadata.page_id == 0] | ||
| first_page_correct = txtlayer_classifier.predict([first_page_lines])[0] | ||
| if first_page_correct: | ||
| return [TxtLayerResult(correct=True, start=start, end=end)] | ||
| return [ | ||
| TxtLayerResult( | ||
| correct=True, | ||
| start=start, | ||
| end=end, | ||
| document=reuse_document, | ||
| detected_pages=detected_pages, | ||
| detected_last_page=detected_last_page | ||
| ) | ||
| ] | ||
| else: | ||
| # the leading pages are not read as one chunk here, so the detection extraction cannot be reused as-is | ||
| return [TxtLayerResult(correct=False, start=start, end=start), TxtLayerResult(correct=True, start=start + 1, end=end)] | ||
|
|
||
| def __classify_each_page( | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -256,6 +256,49 @@ def _split_pdf2image(self, path: str, page_from: int, page_to: int) -> Iterator[ | |
| if page_from >= page_to: | ||
| return | ||
|
|
||
| import os | ||
| # In-process pypdfium2 (PDFium) rendering is ~7% faster end-to-end than pdf2image/pdftoppm (which spawns a | ||
| # poppler subprocess and re-parses the PDF per batch), but is OPT-IN (DEDOC_RENDER=pdfium): on master's | ||
| # Tesseract path it is NOT quality-neutral -- PDFium's thinner glyph anti-aliasing shifts Tesseract's output | ||
| # even with the 2x2 erode (that erode was tuned for the hybrid recognizer), costing ~0.4% word-bag F1 on | ||
| # gen_texts and ~1% body-text similarity vs poppler. The default stays pdftoppm (byte-identical to before). | ||
| if os.environ.get("DEDOC_RENDER", "pdftoppm") == "pdfium": | ||
| try: | ||
| yield from self._split_pdfium(path, page_from, page_to) | ||
| return | ||
| except Exception as error: | ||
| self.logger.warning(f"pypdfium2 render failed ({error}); falling back to pdf2image") | ||
| yield from self._split_pdftoppm(path, page_from, page_to) | ||
|
|
||
| def _split_pdfium(self, path: str, page_from: int, page_to: int) -> Iterator[ndarray]: | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Альтернативный способ рендеринга полезен, возможно даже его настраивать лучше не через переменные окружения, а через конфиг. Но нужно написать для него тест |
||
| """Render pages with pypdfium2 at 200 DPI -> BGR (same resolution/convention as pdf2image, so downstream stages | ||
| are unchanged). PDFium's anti-aliasing renders glyphs ~1 px thinner than Poppler (costs ~3.8% word-bag F1 on | ||
| short text); a 2x2 erode thickens them back to Poppler weight and recovers it (~1 ms/page).""" | ||
| import os | ||
| import math | ||
| import cv2 | ||
| import numpy as np | ||
| import pypdfium2 as pdfium | ||
| from dedoc.utils.pdf_utils import get_pdf_page_count | ||
|
|
||
| page_count = get_pdf_page_count(path) | ||
| page_count = math.inf if page_count is None else page_count | ||
| last = int(min(page_to, page_count)) | ||
| kernel = np.ones((2, 2), np.uint8) | ||
| with open(path, "rb") as content: # load from bytes: a path makes PDFium hold a Windows lock on the file | ||
| pdf = pdfium.PdfDocument(content.read()) | ||
| try: | ||
| for page_index in range(page_from, last): | ||
| bitmap = pdf[page_index].render(scale=200 / 72) # 200 DPI, matching pdf2image's default | ||
| arr = bitmap.to_numpy() | ||
| arr = arr[:, :, :3] if (arr.ndim == 3 and arr.shape[2] == 4) else arr | ||
| image = np.ascontiguousarray(arr[:, :, ::-1]) # RGB -> BGR | ||
| self.logger.info(f"Rendered page {page_index + 1} of {page_count} file {os.path.basename(path)} (pypdfium2)") | ||
| yield cv2.erode(image, kernel, iterations=1) | ||
| finally: | ||
| pdf.close() | ||
|
|
||
| def _split_pdftoppm(self, path: str, page_from: int, page_to: int) -> Iterator[ndarray]: | ||
| import cv2 | ||
| import math | ||
| import os | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -46,14 +46,14 @@ def __evaluation_one_bbox(self, image: np.ndarray, bbox: BBox) -> float: | |
|
|
||
| def __evaluation_one_bbox_image(self, image: np.ndarray) -> float: | ||
| base_line_image = self.__get_base_line_image(image) | ||
| base_line_image_without_spaces = self.__get_rid_spaces(base_line_image) | ||
|
|
||
| # p = fraction of columns with an ink transition, s = ink density. __get_rid_spaces used to sit between | ||
| # base_line_image and s, but its `len(not_space) > 3` guard is the column count (always > 3 for a correct | ||
| # bbox), so it never stripped anything and only wasted a mean(0) -- dropping it is a no-op. (p_img > 0).mean() | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. возможно, в функции прежде чем менять код, лучше потестировать старое поведение с исправлением потенциального бага, потому что текущая реализация определения жирности шрифта далеко не всегда работала хорошо |
||
| # is likewise bit-identical to the old mask-assign-then-mean on the uint8 {0,1} baseline. | ||
| p_img = base_line_image[:, :-1] - base_line_image[:, 1:] | ||
| p_img[abs(p_img) > 0] = 1. | ||
| p_img[p_img < 0] = 0. | ||
| p = p_img.mean() | ||
|
|
||
| s = 1 - base_line_image_without_spaces.mean() | ||
| p = (p_img > 0).mean() | ||
| s = 1 - base_line_image.mean() | ||
|
|
||
| if p > s or s == 0: | ||
| evaluation = 1. | ||
|
|
@@ -67,13 +67,6 @@ def __clusterize(self, bboxes_evaluation: List[float]) -> List[float]: | |
| bboxes_indicators = list(vector_bbox_indicators) | ||
| return bboxes_indicators | ||
|
|
||
| def __get_rid_spaces(self, image: np.ndarray) -> np.ndarray: | ||
| x = image.mean(0) | ||
| not_space = x < 0.95 | ||
| if len(not_space) > 3: | ||
| return image | ||
| return image[:, not_space] | ||
|
|
||
| def __get_base_line_image(self, image: np.ndarray) -> np.ndarray: | ||
| h = image.shape[0] | ||
| if h < self.permissible_h_bbox: | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Над переиспользованием attachments я бы еще подумала - они в большинстве случаев нужны. Можно на уровне auto reader создавать временную директорию и сохранять аттачи туда при первом чтении, тогда они не потеряются при втором чтении