Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
f02ac22
perf(ocr): vectorize the valley-emphasis binarizer (bold classifier)
Jul 13, 2026
6423caf
perf(ocr): binarize in one SIMD pass (cv2.threshold) in the bold bina…
Jul 13, 2026
a27e079
perf(ocr): SIMD color annotation (cv2 inRange + masked sum) -> 4.6x
Jul 14, 2026
a881528
perf(ocr): drop no-op rid_spaces + simplify transition count in bold …
Jul 14, 2026
2eeddc6
perf(table): downscale HoughLinesP for table line detection
OligerMan Jul 7, 2026
035c72a
perf(table): line-crossing gate skips the detector on tableless pages
Jul 14, 2026
da8ec2c
perf(deskew): coarse-to-fine skew search (FastSkewCorrector) -> -18% …
Jul 14, 2026
02a34e5
perf(render): opt-in pypdfium2 rendering (DEDOC_RENDER=pdfium)
Jul 14, 2026
88b54cb
docs: CPU-optimizations report (per-fix speed + quality, cumulative)
Jul 14, 2026
1b4759d
perf(pdf-auto): reuse the text-layer detection extraction for small docs
Jul 14, 2026
a1ddcf8
perf(tabby): parallel page-range chunking of the tabby extraction
Jul 14, 2026
40511f2
perf(annotations): build BBoxAnnotation JSON without the json encoder
Jul 14, 2026
e59296b
perf(tabby-jar): parallel ruling detection + allocation-free pixel sc…
Jul 15, 2026
6aa5837
perf(tables): share line objects in Cell.copy_from instead of deep-co…
Jul 15, 2026
a355dd5
perf(pdf-auto): reuse the textual layer detection extraction for the …
Jul 15, 2026
1d9fa06
perf(tabby): drop the parallel page-range chunking of the tabby extra…
Jul 15, 2026
221f27a
docs: drop CPU_OPTIMIZATIONS_REPORT.md
Jul 16, 2026
d91fda2
fix lint
NastyBoget Jul 31, 2026
b0d845d
fix some table tests
NastyBoget Aug 3, 2026
57b3cca
move fast skew corrector to dedoc-utils
NastyBoget Aug 4, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/workflows/docs.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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: |
Expand Down
11 changes: 8 additions & 3 deletions dedoc/data_structures/concrete_annotations/bbox_annotation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]:
Expand Down
13 changes: 9 additions & 4 deletions dedoc/readers/pdf_reader/data_classes/tables/cell.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
4 changes: 4 additions & 0 deletions dedoc/readers/pdf_reader/pdf_auto_reader/pdf_auto_reader.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
48 changes: 43 additions & 5 deletions dedoc/readers/pdf_reader/pdf_auto_reader/txtlayer_detector.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Над переиспользованием attachments я бы еще подумала - они в большинстве случаев нужны. Можно на уровне auto reader создавать временную директорию и сохранять аттачи туда при первом чтении, тогда они не потеряются при втором чтении

)
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

@NastyBoget NastyBoget Jul 31, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The 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(
Expand Down
6 changes: 6 additions & 0 deletions dedoc/readers/pdf_reader/pdf_auto_reader/txtlayer_result.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
43 changes: 43 additions & 0 deletions dedoc/readers/pdf_reader/pdf_base_reader.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

возможно, в функции __get_rid_spaces был баг, и нужно было проверять not_space.sum()

прежде чем менять код, лучше потестировать старое поведение с исправлением потенциального бага, потому что текущая реализация определения жирности шрифта далеко не всегда работала хорошо

# 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.
Expand All @@ -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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import re
from typing import List, Optional

import cv2
import numpy as np
from numpy import median

Expand All @@ -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:

Expand Down Expand Up @@ -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)
Loading
Loading