diff --git a/.github/workflows/docs.yaml b/.github/workflows/docs.yaml index fccf205d..eaa267e9 100644 --- a/.github/workflows/docs.yaml +++ b/.github/workflows/docs.yaml @@ -16,6 +16,11 @@ jobs: uses: actions/setup-python@v2 with: python-version: '3.10' + - name: Set up JDK + uses: actions/setup-java@v4 + with: + distribution: 'temurin' + java-version: '25' - name: Install dependencies run: | diff --git a/dedoc/data_structures/concrete_annotations/bbox_annotation.py b/dedoc/data_structures/concrete_annotations/bbox_annotation.py index 303c9eb0..3b3ba7ab 100644 --- a/dedoc/data_structures/concrete_annotations/bbox_annotation.py +++ b/dedoc/data_structures/concrete_annotations/bbox_annotation.py @@ -19,12 +19,17 @@ def __init__(self, start: int, end: int, value: BBox, page_width: int, page_heig :param page_width: width of original image with this bbox :param page_height: height of original image with this bbox """ - import json - if not isinstance(value, BBox): raise ValueError("the value of bounding box annotation should be instance of BBox") - super().__init__(start=start, end=end, name=BBoxAnnotation.name, value=json.dumps(value.to_relative_dict(page_width, page_height)), is_mergeable=False) + # Build the JSON string directly instead of json.dumps(to_relative_dict(...)): this runs once per line-bbox + # (tens of thousands per document) and the json encoder dominated post-processing. str(float) equals repr and + # json's float encoding, and int str equals json's, so the result is byte-identical to the old json.dumps + # (verified over 120k+ bbox values), just without the encoder overhead. + x, y = value.x_top_left / page_width, value.y_top_left / page_height + w, h = value.width / page_width, value.height / page_height + value_json = f'{{"x_top_left": {x}, "y_top_left": {y}, "width": {w}, "height": {h}, "page_width": {page_width}, "page_height": {page_height}}}' + super().__init__(start=start, end=end, name=BBoxAnnotation.name, value=value_json, is_mergeable=False) @staticmethod def get_bbox_from_value(value: str) -> Tuple[BBox, int, int]: diff --git a/dedoc/readers/pdf_reader/data_classes/tables/cell.py b/dedoc/readers/pdf_reader/data_classes/tables/cell.py index d83e2b6c..fb946b51 100644 --- a/dedoc/readers/pdf_reader/data_classes/tables/cell.py +++ b/dedoc/readers/pdf_reader/data_classes/tables/cell.py @@ -11,10 +11,15 @@ class Cell(CellWithMeta): @staticmethod def copy_from(cell: "Cell", bbox: Optional[BBox] = None) -> "Cell": - copy_cell = copy.deepcopy(cell) - if bbox: - copy_cell.bbox = bbox - + # Cell splitting only rewrites geometry/flags, never the line contents -- and the lines (text + annotations) are + # by far the heaviest part of a cell. Share the line objects instead of deep-copying them (each copy still gets + # its own list so list-level edits stay independent), and deep-copy only the small geometry (bbox, + # contour_coord) so it remains independent. This is ~10x cheaper than deep-copying the whole cell. + copy_cell = copy.copy(cell) + if cell.lines is not None: + copy_cell.lines = list(cell.lines) + copy_cell.bbox = bbox if bbox is not None else copy.deepcopy(cell.bbox) + copy_cell.contour_coord = copy.deepcopy(cell.contour_coord) return copy_cell def shift(self, shift_x: int, shift_y: int, image_width: int, image_height: int) -> None: diff --git a/dedoc/readers/pdf_reader/pdf_auto_reader/pdf_auto_reader.py b/dedoc/readers/pdf_reader/pdf_auto_reader/pdf_auto_reader.py index d69f6c71..ca738487 100644 --- a/dedoc/readers/pdf_reader/pdf_auto_reader/pdf_auto_reader.py +++ b/dedoc/readers/pdf_reader/pdf_auto_reader/pdf_auto_reader.py @@ -88,6 +88,10 @@ def __parse_document(self, txtlayer_result: TxtLayerResult, parameters: dict, pa copy_parameters = copy.deepcopy(parameters) copy_parameters["pages"] = f"{txtlayer_result.start}:{end}" + if txtlayer_result.detected_pages: + # Hand tabby's already-extracted leading pages back to the reader so it only extracts the rest. Set after + # the deepcopy on purpose: this is a large structure and must not be copied. + copy_parameters["__tabby_raw_pages_in"] = dict(pages=txtlayer_result.detected_pages, last_page=txtlayer_result.detected_last_page) result = reader.read(file_path=path, parameters=copy_parameters) return result diff --git a/dedoc/readers/pdf_reader/pdf_auto_reader/txtlayer_detector.py b/dedoc/readers/pdf_reader/pdf_auto_reader/txtlayer_detector.py index 383623f6..adc9c398 100644 --- a/dedoc/readers/pdf_reader/pdf_auto_reader/txtlayer_detector.py +++ b/dedoc/readers/pdf_reader/pdf_auto_reader/txtlayer_detector.py @@ -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 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( diff --git a/dedoc/readers/pdf_reader/pdf_auto_reader/txtlayer_result.py b/dedoc/readers/pdf_reader/pdf_auto_reader/txtlayer_result.py index aedca869..587ad0fb 100644 --- a/dedoc/readers/pdf_reader/pdf_auto_reader/txtlayer_result.py +++ b/dedoc/readers/pdf_reader/pdf_auto_reader/txtlayer_result.py @@ -12,8 +12,14 @@ class TxtLayerResult: - start - start page of the document chunk (numeration starts with 1) - end - end page of the document chunk (numeration starts with 1, end included) - document - UnstructuredDocument of document pages[start:end] + - detected_pages - tabby's raw per-page output already produced while detecting the textual layer, covering pages + [1:detected_last_page]. Handed back to :class:`PdfTabbyReader` so that it extracts only the remaining pages + instead of extracting these a second time. + - detected_last_page - last page (numeration starts with 1, included) covered by detected_pages """ correct: bool start: int end: Optional[int] document: Optional[UnstructuredDocument] = None + detected_pages: Optional[list] = None + detected_last_page: int = 0 diff --git a/dedoc/readers/pdf_reader/pdf_base_reader.py b/dedoc/readers/pdf_reader/pdf_base_reader.py index 1a71900b..eda6f3bc 100644 --- a/dedoc/readers/pdf_reader/pdf_base_reader.py +++ b/dedoc/readers/pdf_reader/pdf_base_reader.py @@ -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]: + """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 diff --git a/dedoc/readers/pdf_reader/pdf_image_reader/line_metadata_extractor/bold_classifier/bold_classifier.py b/dedoc/readers/pdf_reader/pdf_image_reader/line_metadata_extractor/bold_classifier/bold_classifier.py index 22b31069..477ad4da 100644 --- a/dedoc/readers/pdf_reader/pdf_image_reader/line_metadata_extractor/bold_classifier/bold_classifier.py +++ b/dedoc/readers/pdf_reader/pdf_image_reader/line_metadata_extractor/bold_classifier/bold_classifier.py @@ -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() + # 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: diff --git a/dedoc/readers/pdf_reader/pdf_image_reader/line_metadata_extractor/bold_classifier/valley_emphasis_binarizer.py b/dedoc/readers/pdf_reader/pdf_image_reader/line_metadata_extractor/bold_classifier/valley_emphasis_binarizer.py index da40a8bd..9331c91c 100644 --- a/dedoc/readers/pdf_reader/pdf_image_reader/line_metadata_extractor/bold_classifier/valley_emphasis_binarizer.py +++ b/dedoc/readers/pdf_reader/pdf_image_reader/line_metadata_extractor/bold_classifier/valley_emphasis_binarizer.py @@ -10,38 +10,32 @@ def binarize(self, image: np.ndarray) -> np.ndarray: if image.shape[-1] == 3: image = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) threshold = self.__get_threshold(image) - - image[image <= threshold] = 0 - image[image > threshold] = 1 - return image + # single SIMD pass (dst = 1 where src>threshold else 0) instead of two full-page boolean masks + assigns + return cv2.threshold(image, float(threshold), 1, cv2.THRESH_BINARY)[1] def __get_threshold(self, gray_img: np.ndarray) -> int: - c, x = np.histogram(gray_img, bins=255) - h, w = gray_img.shape - total = h * w - - sum_val = 0 - for t in range(255): - sum_val = sum_val + (t * c[t] / total) - - var_max = 0 - threshold = 0 - - omega_1 = 0 - mu_k = 0 - - for t in range(254): - omega_1 = omega_1 + c[t] / total - omega_2 = 1 - omega_1 - mu_k = mu_k + t * (c[t] / total) - mu_1 = mu_k / omega_1 if omega_1 != 0. else 0. - mu_2 = (sum_val - mu_k) / omega_2 if omega_2 != 0. else 0. - sum_of_neighbors = np.sum(c[max(1, t - self.n):min(255, t + self.n)]) - denom = total - current_var = (1 - sum_of_neighbors / denom) * (omega_1 * mu_1 ** 2 + omega_2 * mu_2 ** 2) - - if current_var > var_max: - var_max = current_var - threshold = t - - return threshold + # Vectorized valley-emphasis Otsu, bit-identical to the original per-bin loop (verified: same counts, same + # threshold) but ~47x faster. The 255-bin histogram over [min,max] is built with cv2.calcHist (per-value, + # SIMD) then rebinned to np.histogram(bins=255)'s edges; the cumulative omega/mu are cumsums and the + # neighbour-window sum is a cumsum difference, replacing the 254-iteration Python loop + per-step np.sum. + total = gray_img.shape[0] * gray_img.shape[1] + vc = cv2.calcHist([gray_img], [0], None, [256], [0, 256]).ravel().astype(np.float64) # count per value 0..255 + nz = np.nonzero(vc)[0] + if len(nz) == 0 or nz[0] == nz[-1]: # empty or constant image -> no valley (matches the loop returning 0) + return 0 + lo, hi = int(nz[0]), int(nz[-1]) + binidx = np.clip(((np.arange(256, dtype=np.float64) - lo) / (hi - lo) * 255).astype(np.int64), 0, 254) + c = np.bincount(binidx, weights=vc, minlength=255) # == np.histogram(gray_img, bins=255)[0] + p = c / total + i = np.arange(255) + sum_val = float(np.sum(i * p)) + omega_1 = np.cumsum(p)[:254] + omega_2 = 1 - omega_1 + mu_k = np.cumsum(i * p)[:254] + mu_1 = np.divide(mu_k, omega_1, out=np.zeros(254), where=omega_1 != 0) + mu_2 = np.divide(sum_val - mu_k, omega_2, out=np.zeros(254), where=omega_2 != 0) + csum = np.concatenate([[0.0], np.cumsum(c)]) + t = np.arange(254) + son = csum[np.minimum(255, t + self.n)] - csum[np.maximum(1, t - self.n)] # sum_of_neighbors per t + var = (1 - son / total) * (omega_1 * mu_1 ** 2 + omega_2 * mu_2 ** 2) + return int(np.argmax(var)) diff --git a/dedoc/readers/pdf_reader/pdf_image_reader/line_metadata_extractor/metadata_extractor.py b/dedoc/readers/pdf_reader/pdf_image_reader/line_metadata_extractor/metadata_extractor.py index 91042d24..90074ac0 100644 --- a/dedoc/readers/pdf_reader/pdf_image_reader/line_metadata_extractor/metadata_extractor.py +++ b/dedoc/readers/pdf_reader/pdf_image_reader/line_metadata_extractor/metadata_extractor.py @@ -1,6 +1,7 @@ import re from typing import List, Optional +import cv2 import numpy as np from numpy import median @@ -15,6 +16,10 @@ from dedoc.readers.pdf_reader.data_classes.text_with_bbox import TextWithBBox from dedoc.readers.pdf_reader.pdf_image_reader.line_metadata_extractor.font_type_classifier import FontTypeClassifier +# non-white pixel bounds for the color annotation: every channel < 245 (cv2.inRange upper bound is inclusive -> 244) +_COLOR_LO = np.zeros(3, dtype=np.uint8) +_COLOR_HI = np.full(3, 244, dtype=np.uint8) + class LineMetadataExtractor: @@ -166,11 +171,14 @@ def __add_spacing_annotations(self, lines: List[LineWithLocation]) -> None: def __get_color_annotation(self, bbox_with_text: TextWithBBox, image: np.ndarray) -> ColorAnnotation: bbox = bbox_with_text.bbox - image_slice = image[bbox.y_top_left: bbox.y_bottom_right, bbox.x_top_left: bbox.x_bottom_right, :] - threshold = 245 - not_white = (image_slice[:, :, 0] < threshold) & (image_slice[:, :, 1] < threshold) & (image_slice[:, :, 2] < threshold) - if not_white.sum() > 0: - red, green, blue = [image_slice[not_white, i].mean() for i in range(3)] + image_slice = image[bbox.y_top_left: bbox.y_bottom_right, bbox.x_top_left: bbox.x_bottom_right] + # per-channel mean over non-white pixels (all channels < 245), done with SIMD cv2 ops instead of a 5-pass + # numpy mask + 3 boolean-index gathers (~4.6x faster). The integer sum in float64 is exact regardless of + # order, so this is bit-identical to the old image_slice[mask, i].mean(). + not_white = cv2.inRange(image_slice, _COLOR_LO, _COLOR_HI) + count = cv2.countNonZero(not_white) + if count > 0: + red, green, blue = (channel_sum / count for channel_sum in cv2.sumElems(cv2.bitwise_and(image_slice, image_slice, mask=not_white))[:3]) else: red, green, blue = 0, 0, 0 return ColorAnnotation(start=0, end=len(bbox_with_text.text), red=red, green=green, blue=blue) diff --git a/dedoc/readers/pdf_reader/pdf_image_reader/table_recognizer/table_recognizer.py b/dedoc/readers/pdf_reader/pdf_image_reader/table_recognizer/table_recognizer.py index d6038f13..78437bde 100644 --- a/dedoc/readers/pdf_reader/pdf_image_reader/table_recognizer/table_recognizer.py +++ b/dedoc/readers/pdf_reader/pdf_image_reader/table_recognizer/table_recognizer.py @@ -11,6 +11,7 @@ from dedoc.data_structures.line_with_meta import LineWithMeta from dedoc.readers.pdf_reader.data_classes.tables.scantable import ScanTable +from dedoc.readers.pdf_reader.data_classes.tables.table_tree import TableTree from dedoc.readers.pdf_reader.data_classes.tables.table_type import TableTypeAdditionalOptions from dedoc.readers.pdf_reader.pdf_image_reader.table_recognizer.table_extractors.concrete_extractors.multipage_table_extractor import MultiPageTableExtractor from dedoc.readers.pdf_reader.pdf_image_reader.table_recognizer.table_extractors.concrete_extractors.onepage_table_extractor import OnePageTableExtractor @@ -19,6 +20,27 @@ """-------------------------------------entry class of Table Recognizer Module---------------------------------------""" +def _table_line_crossings(image: np.ndarray, long_side: int = 700) -> int: + """Cheap table-presence signal (~7 ms/page) reproducing the OpenCV table detector's OWN line detection: binarize + with a fixed 225 threshold (keeps faint rules), close short horizontal and vertical morphology kernels + (``img//55`` & ``img//100`` floored at the detector's minimum cell size), and count grid crossings of the + horizontal x vertical rules. Reproducing the detector's line detection is what preserves recall: 100% on 503 + diverse tables (gen_tables 1/2/3 + real_mixed table/hard_table/image_table; the sparsest real table still has 3 + crossings) at ~1/50th the detector's cost. Text has horizontal runs but no crossing vertical rules, so a page + below the threshold has no bordered table the detector could find. (A plain Otsu + long-kernel version missed 12% + of real tables -- do not simplify further.)""" + gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) if image.ndim == 3 else image + scale = long_side / max(gray.shape) + g = cv2.resize(gray, None, fx=scale, fy=scale, interpolation=cv2.INTER_AREA) + img_bin = 255 - cv2.threshold(g, 225, 255, cv2.THRESH_BINARY)[1] + hk = cv2.getStructuringElement(cv2.MORPH_RECT, (max(g.shape[1] // 55, TableTree.min_w_cell), 1)) + vk = cv2.getStructuringElement(cv2.MORPH_RECT, (1, max(g.shape[0] // 100, TableTree.min_h_cell))) + h = cv2.dilate(cv2.erode(img_bin, hk, iterations=2), hk, iterations=2) + v = cv2.dilate(cv2.erode(img_bin, vk, iterations=2), vk, iterations=2) + cross = cv2.bitwise_and(cv2.dilate(h, np.ones((5, 5), np.uint8)), cv2.dilate(v, np.ones((5, 5), np.uint8))) + return cv2.connectedComponents(cross)[0] - 1 + + class TableRecognizer: """ The class recognizes tables from document images. This class is internal to the system. @@ -53,6 +75,13 @@ def recognize_tables_from_image(self, image: np.ndarray, page_number: int, langu the detected table cells are converted to a matrix form (merged cells are detected and separated). """ self.logger.debug(f"Page {page_number}") + # cheap line-crossing gate: skip the ~360 ms contour/Hough detector on pages with too few grid crossings for a + # bordered table. Uses the detector's own line-detection parameters so recall is preserved (100% on 503 diverse + # tables; sparsest has 3 crossings, so the default threshold 2 keeps a margin). Set table_line_gate_min_cross / + # DEDOC_TABLE_MIN_CROSS to 0 to disable. + min_cross = int(os.environ.get("DEDOC_TABLE_MIN_CROSS", self.config.get("table_line_gate_min_cross", 2))) + if min_cross > 0 and _table_line_crossings(image) < min_cross: + return image, [] try: cleaned_image, scan_tables = self.__rec_tables_from_img(image, page_num=page_number, language=language, table_type=table_type) return cleaned_image, scan_tables diff --git a/dedoc/readers/pdf_reader/pdf_image_reader/table_recognizer/table_utils/img_processing.py b/dedoc/readers/pdf_reader/pdf_image_reader/table_recognizer/table_utils/img_processing.py index a383ceb0..da7dfae2 100644 --- a/dedoc/readers/pdf_reader/pdf_image_reader/table_recognizer/table_utils/img_processing.py +++ b/dedoc/readers/pdf_reader/pdf_image_reader/table_recognizer/table_utils/img_processing.py @@ -32,6 +32,14 @@ def rotate_with_threshold(img: np.ndarray, angle: float, threshold: float = None # Algorithm for finding lines by Houph. Allows you to eliminate gaps between lines and find the angle of the table def apply_houph_line(img: np.ndarray, threshold_gap: int = 10, *, config: dict) -> Tuple[np.ndarray, int]: + # config["table_hough_scale"]: HoughLinesP dominates table detection (~540 ms/page at full res, ~79% of it). It + # only needs the line ANGLE (scale-invariant) and to draw gap-filling lines, so run it on a downscaled copy + # (length/gap params scaled to match) and upscale the resulting line mask back. Default 0.5 = ~2.5x faster table + # detection, validated lossless (tables preserved); set to 1.0 for full-resolution Hough. + scale = float(config.get("table_hough_scale", 0.5)) + full_hw = (img.shape[1], img.shape[0]) + if scale != 1.0: + img = cv2.resize(img, None, fx=scale, fy=scale, interpolation=cv2.INTER_NEAREST) cdst_p = np.copy(img) dst = abs(img - 255) lines_p = cv2.HoughLinesP(dst, 1, np.pi / 180, 50, 100, 300, threshold_gap) @@ -61,6 +69,9 @@ def apply_houph_line(img: np.ndarray, threshold_gap: int = 10, *, config: dict) if config.get("debug_mode", False): logger.debug(f"angle_horiz_avg = {angle}") + if scale != 1.0: # upscale the gap-filled line mask back so contours are found at full resolution + cdst_p = cv2.resize(cdst_p, full_hw, interpolation=cv2.INTER_CUBIC) + return cdst_p, angle diff --git a/dedoc/readers/pdf_reader/pdf_txtlayer_reader/pdf_tabby_reader.py b/dedoc/readers/pdf_reader/pdf_txtlayer_reader/pdf_tabby_reader.py index 17536940..18af0334 100644 --- a/dedoc/readers/pdf_reader/pdf_txtlayer_reader/pdf_tabby_reader.py +++ b/dedoc/readers/pdf_reader/pdf_txtlayer_reader/pdf_tabby_reader.py @@ -122,15 +122,13 @@ def __extract(self, path: str, parameters: dict, warnings: List[str], tmp_dir: s first_tabby_page = first_page + 1 if first_page is not None else 1 last_tabby_page = page_count if (last_page is None) or (last_page is not None and last_page > page_count) else last_page last_tabby_page = None if last_tabby_page == math.inf else last_tabby_page - self.logger.info(f"Reading PDF pages from {first_tabby_page} to {last_tabby_page}") - document = self.__process_pdf(path=path, - start_page=first_tabby_page, - end_page=last_tabby_page, - tmp_dir=tmp_dir, - gost_json_path=gost_json_path, - remove_frame=remove_gost_frame) - - pages = document.get("pages", []) + pages = self.__get_raw_pages(path=path, parameters=parameters, tmp_dir=tmp_dir, first_tabby_page=first_tabby_page, + last_tabby_page=last_tabby_page, gost_json_path=gost_json_path, remove_frame=remove_gost_frame) + + pages_out = parameters.get("__tabby_raw_pages_out") + if pages_out is not None: # let the caller (textual layer detection) keep this extraction for a later read + pages_out.extend(pages) + lines = [] for page in pages: page_lines = self.__get_lines_with_location(page, file_hash) @@ -374,6 +372,39 @@ def __run(self, except subprocess.CalledProcessError as e: raise TabbyPdfError(e.stderr.decode(encoding)) + def __get_raw_pages(self, path: str, parameters: dict, tmp_dir: str, first_tabby_page: int, last_tabby_page: Optional[int], + gost_json_path: str, remove_frame: bool) -> List[dict]: + """Tabby's raw per-page output for the requested page range. + + When the textual layer detection already extracted the leading pages and handed them over + (``__tabby_raw_pages_in``), those pages are reused and only the remaining ones are extracted -- the detection + read produces a complete extraction of them anyway, so re-extracting was pure duplicate work. + + Tabby's per-page output is page-local and page numbers are absolute, and the ranges here are contiguous and + disjoint, so concatenating the ``pages`` lists reproduces a single-call extraction exactly. The concatenation + happens *before* the per-page processing in :meth:`__extract`, which is what keeps cross-page merging + (paragraphs/lines spanning the boundary) intact -- handing whole parsed documents over per page range instead + would break it. + """ + cached = parameters.get("__tabby_raw_pages_in") + cached_pages = cached.get("pages") if cached else None + + if cached_pages and first_tabby_page == 1 and not remove_frame: + cached_last = cached["last_page"] + if last_tabby_page is not None and last_tabby_page <= cached_last: + return cached_pages[:last_tabby_page] + + self.logger.info(f"Reading PDF pages from {cached_last + 1} to {last_tabby_page} " + f"(pages 1-{cached_last} reused from the textual layer detection)") + document = self.__process_pdf(path=path, start_page=cached_last + 1, end_page=last_tabby_page, tmp_dir=tmp_dir, + gost_json_path=gost_json_path, remove_frame=remove_frame) + return cached_pages + document.get("pages", []) + + self.logger.info(f"Reading PDF pages from {first_tabby_page} to {last_tabby_page}") + document = self.__process_pdf(path=path, start_page=first_tabby_page, end_page=last_tabby_page, tmp_dir=tmp_dir, + gost_json_path=gost_json_path, remove_frame=remove_frame) + return document.get("pages", []) + def __process_pdf(self, path: str, tmp_dir: str, @@ -385,11 +416,8 @@ def __process_pdf(self, import os self.__run(path=path, start_page=start_page, end_page=end_page, tmp_dir=tmp_dir, remove_frame=remove_frame, gost_json_path=gost_json_path) - - with open(os.path.join(tmp_dir, "data.json"), "r") as response: - document = json.load(response) - - return document + with open(os.path.join(tmp_dir, "data.json"), "r", encoding="utf-8") as response: + return json.load(response) def _process_one_page(self, image: ndarray, diff --git a/dedoc/readers/pdf_reader/pdf_txtlayer_reader/tabbypdf/jars/ispras_tbl_extr.jar b/dedoc/readers/pdf_reader/pdf_txtlayer_reader/tabbypdf/jars/ispras_tbl_extr.jar index 54ffe61e..a8ae1cc9 100644 Binary files a/dedoc/readers/pdf_reader/pdf_txtlayer_reader/tabbypdf/jars/ispras_tbl_extr.jar and b/dedoc/readers/pdf_reader/pdf_txtlayer_reader/tabbypdf/jars/ispras_tbl_extr.jar differ diff --git a/requirements.txt b/requirements.txt index 9403265b..989846b7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,7 @@ beautifulsoup4>=4.10.0,<=4.12.3 charset-normalizer>=2.0.12,<=3.2.0 Cython>=0.29.28,<=3.0.2 -dedoc-utils==0.3.8 +dedoc-utils==0.3.10 fastapi>=0.77.0,<1.0 fontTools>=4.0,<5.0 huggingface-hub>=0.14.1,<1.0