From 71ca05cb11fd9d510211c7c4df79fcde5103cbe4 Mon Sep 17 00:00:00 2001 From: conjuncts <67614673+conjuncts@users.noreply.github.com> Date: Sun, 22 Jun 2025 11:46:04 -0500 Subject: [PATCH 1/7] Change paths, type hint predictions --- gmft/base.py | 2 +- .../{exceptions => exception}/__init__.py | 0 gmft/core/ml/prediction/__init__.py | 86 +++++++++++++++++++ gmft/formatters/tatr.py | 11 +-- gmft/pdf_bindings/pdfium.py | 2 +- 5 files changed, 94 insertions(+), 7 deletions(-) rename gmft/core/{exceptions => exception}/__init__.py (100%) create mode 100644 gmft/core/ml/prediction/__init__.py diff --git a/gmft/base.py b/gmft/base.py index 261cfa6..8a50cd8 100644 --- a/gmft/base.py +++ b/gmft/base.py @@ -1,5 +1,5 @@ from typing import TypeVar, Union -from gmft.core.exceptions import DocumentClosedException +from gmft.core.exception import DocumentClosedException class Rect: diff --git a/gmft/core/exceptions/__init__.py b/gmft/core/exception/__init__.py similarity index 100% rename from gmft/core/exceptions/__init__.py rename to gmft/core/exception/__init__.py diff --git a/gmft/core/ml/prediction/__init__.py b/gmft/core/ml/prediction/__init__.py new file mode 100644 index 0000000..eed33ab --- /dev/null +++ b/gmft/core/ml/prediction/__init__.py @@ -0,0 +1,86 @@ +from typing import Tuple, TypedDict, List, Union + +# Type definitions for predictions structure +class RawBboxPredictions(TypedDict): + """Type definition for a single model's bbox prediction output.""" + scores: List[float] + labels: List[int] + boxes: List[List[float]] + +class BboxPrediction(TypedDict): + confidence: float + label: str + bbox: Tuple[float, float, float, float] + +class EffectivePredictions(TypedDict): + """ + Effective rows/columns/etc as seen by the image --> df algorithm. + + May be postprocessed from the table structure recognition model of choice (ie. TATR). + """ + rows: List[BboxPrediction] + + columns: List[BboxPrediction] + + headers: List[BboxPrediction] + + projecting: List[BboxPrediction] + "Projected rows as seen by the image --> df algorithm." + + spanning: List[BboxPrediction] + "Spanning cells as seen by the image --> df algorithm." + + +class TablePredictions(TypedDict): + """Type definition for the complete predictions dictionary.""" + tatr: RawBboxPredictions + + effective: EffectivePredictions + +# predictions: Predictions = { +# "tatr": { +# "scores": [ +# 0.9999045133590698, +# 0.9998310804367065, +# 0.9999147653579712, +# 0.9998205304145813, +# 0.9999688863754272, +# 0.9998650550842285, +# 0.9998096823692322, +# 0.9897574186325073, +# 0.9998759031295776, +# ], +# "labels": [2, 2, 1, 2, 1, 1, 2, 3, 0], +# "boxes": [ +# [ +# 71.36495971679688, +# 159.0726318359375, +# 797.0186767578125, +# 206.53753662109375, +# ], +# [ +# 70.94971466064453, +# 110.53954315185547, +# 797.128173828125, +# 158.9207000732422, +# ], +# [71.17463684082031, 73.58935546875, 329.6531677246094, 244.5222625732422], +# [71.1388931274414, 73.6107177734375, 797.3575439453125, 109.99236297607422], +# [331.3564147949219, 73.64269256591797, 576.944091796875, 244.3546905517578], +# [ +# 575.6424560546875, +# 73.62675476074219, +# 797.5115356445312, +# 244.22035217285156, +# ], +# [71.27164459228516, 206.5450439453125, 796.82958984375, 244.68435668945312], +# [ +# 71.13404083251953, +# 73.61981964111328, +# 797.3654174804688, +# 109.93215942382812, +# ], +# [71.12321472167969, 73.54254150390625, 797.08642578125, 244.42941284179688], +# ], +# } +# } \ No newline at end of file diff --git a/gmft/formatters/tatr.py b/gmft/formatters/tatr.py index 5a29eb5..fcb8f05 100644 --- a/gmft/formatters/tatr.py +++ b/gmft/formatters/tatr.py @@ -3,6 +3,7 @@ from gmft.core._dataclasses import non_defaults_only, with_config from gmft.core.ml import _resolve_device +from gmft.core.ml.prediction import BboxPrediction from gmft.detectors.base import CroppedTable, RotatedCroppedTable from gmft.impl.tatr.config import TATRFormatConfig from gmft.formatters.base import FormattedTable, TableFormatter, _normalize_bbox @@ -44,19 +45,19 @@ class TATRFormattedTable(FormattedTable): config: TATRFormatConfig outliers: dict[str, bool] - effective_rows: list[tuple] + effective_rows: list[BboxPrediction] "Rows as seen by the image --> df algorithm, which may differ from what the table transformer sees." - effective_columns: list[tuple] + effective_columns: list[BboxPrediction] "Columns as seen by the image --> df algorithm, which may differ from what the table transformer sees." - effective_headers: list[tuple] + effective_headers: list[BboxPrediction] "Headers as seen by the image --> df algorithm." - effective_projecting: list[tuple] + effective_projecting: list[BboxPrediction] "Projected rows as seen by the image --> df algorithm." - effective_spanning: list[tuple] + effective_spanning: list[BboxPrediction] "Spanning cells as seen by the image --> df algorithm." _top_header_indices: list[int] = None diff --git a/gmft/pdf_bindings/pdfium.py b/gmft/pdf_bindings/pdfium.py index 767eb44..6e1b5a6 100644 --- a/gmft/pdf_bindings/pdfium.py +++ b/gmft/pdf_bindings/pdfium.py @@ -5,7 +5,7 @@ import pypdfium2 as pdfium from gmft.base import Rect -from gmft.core.exceptions import DocumentClosedException +from gmft.core.exception import DocumentClosedException from gmft.pdf_bindings.base import BasePDFDocument, BasePage, _infer_line_breaks from PIL.Image import Image as PILImage From e7704d14ec1ca159ea28bdec6de74a448d3ba637 Mon Sep 17 00:00:00 2001 From: conjuncts <67614673+conjuncts@users.noreply.github.com> Date: Sun, 22 Jun 2025 15:13:47 -0500 Subject: [PATCH 2/7] Contain and type-hint predictions --- gmft/algorithm/structure.py | 43 ++++++----- gmft/auto.py | 7 ++ gmft/core/io/__init__.py | 0 gmft/core/io/serial/dicts.py | 59 +++++++++++++++ gmft/core/legacy/fctn_results.py | 104 ++++++++++++++++++++++++++ gmft/core/ml/__init__.py | 11 ++- gmft/core/ml/prediction/__init__.py | 41 +++++++++- gmft/formatters/base.py | 3 + gmft/formatters/ditr.py | 95 +++++++++-------------- gmft/formatters/tatr.py | 91 +++++++--------------- test/formatters/ditr/test_df.py | 2 +- test/formatters/histogram/test_df.py | 2 +- test/formatters/tatr/test_df.py | 12 +-- test/formatters/tatr/test_spanning.py | 2 +- 14 files changed, 313 insertions(+), 159 deletions(-) create mode 100644 gmft/core/io/__init__.py create mode 100644 gmft/core/io/serial/dicts.py create mode 100644 gmft/core/legacy/fctn_results.py diff --git a/gmft/algorithm/structure.py b/gmft/algorithm/structure.py index 63483e2..ed53969 100644 --- a/gmft/algorithm/structure.py +++ b/gmft/algorithm/structure.py @@ -7,6 +7,11 @@ from gmft.base import Rect from typing import TYPE_CHECKING +from gmft.core.ml.prediction import ( + _empty_effective_predictions, + _empty_indices_predictions, +) + if TYPE_CHECKING: from gmft.impl.tatr.config import TATRFormatConfig from gmft.formatters.tatr import TATRFormattedTable @@ -772,7 +777,7 @@ def extract_to_df(table: TATRFormattedTable, config: TATRFormatConfig = None): outliers = {} # store table-wide information about outliers or pecularities - results = table.fctn_results + results = table.predictions["tatr"] # 1. collate identified boxes boxes = [] @@ -894,14 +899,8 @@ def extract_to_df(table: TATRFormattedTable, config: TATRFormatConfig = None): if not known_means: # no text was detected outliers["no text"] = True - table.effective_rows = [] - table.effective_columns = [] - table.effective_headers = [] - table.effective_projecting = [] - table.effective_spanning = [] - table._top_header_indices = [] - table._projecting_indices = [] - table._hier_left_indices = [] + table.predictions["effective"] = _empty_effective_predictions() + table.predictions["indices"] = _empty_indices_predictions() table._df = pd.DataFrame() table.outliers = outliers return table._df @@ -941,12 +940,13 @@ def extract_to_df(table: TATRFormattedTable, config: TATRFormatConfig = None): ) # nms takes care of deduplication - - table.effective_rows = sorted_rows - table.effective_columns = sorted_columns - table.effective_headers = sorted_headers - table.effective_projecting = sorted_projecting - table.effective_spanning = spanning_cells + table.predictions["effective"] = { + "rows": sorted_rows, + "columns": sorted_columns, + "headers": sorted_headers, + "projecting": sorted_projecting, + "spanning": spanning_cells, + } # 4b. check for catastrophic overlap total_column_area = 0 @@ -1004,6 +1004,7 @@ def extract_to_df(table: TATRFormattedTable, config: TATRFormatConfig = None): ) # semantic spanning fill + indices_preds = {} if config.semantic_spanning_cells: sorted_headers_bboxes = [x["bbox"] for x in sorted_headers] sorted_row_bboxes = [x["bbox"] for x in sorted_rows] @@ -1037,15 +1038,15 @@ def extract_to_df(table: TATRFormattedTable, config: TATRFormatConfig = None): header_indices=header_indices, config=config, ) - table._hier_left_indices = hier_left_idxs + indices_preds["_hier_left"] = hier_left_idxs else: - table._hier_left_indices = [] # for the user + indices_preds["_hier_left"] = [] # for the user # technically these indices will be off by the number of header rows ;-; if config.enable_multi_header: - table._top_header_indices = header_indices + indices_preds["_top_header"] = header_indices else: - table._top_header_indices = [0] if header_indices else [] + indices_preds["_top_header"] = [0] if header_indices else [] # extract out the headers header_rows = table_array[header_indices] @@ -1078,7 +1079,9 @@ def extract_to_df(table: TATRFormattedTable, config: TATRFormatConfig = None): is_projecting = [ x for i, x in enumerate(is_projecting) if i not in header_indices ] - table._projecting_indices = [i for i, x in enumerate(is_projecting) if x] + indices_preds["_projecting"] = [i for i, x in enumerate(is_projecting) if x] + + table.predictions["indices"] = indices_preds # if projecting_indices: # insert at end diff --git a/gmft/auto.py b/gmft/auto.py index 80e81e2..ec4ccbf 100644 --- a/gmft/auto.py +++ b/gmft/auto.py @@ -22,6 +22,7 @@ TATRTableFormatter = TATRFormatter # TATRFormatConfig = TATRFormatConfig + class AutoTableFormatter: """ The recommended :class:`~gmft.formatters.base.BaseFormatter`. Currently points to :class:`~gmft.formatters.tatr.TATRFormatter`. @@ -29,8 +30,10 @@ class AutoTableFormatter: Using :meth:`extract`, a :class:`~gmft.formatters.base.FormattedTable` is produced, which can be exported to csv, df, etc. """ + def __new__(cls, *args, **kwargs): from gmft.formatters.tatr import TATRFormatter + return TATRFormatter(*args, **kwargs) @@ -38,8 +41,10 @@ class AutoFormatConfig: """ Configuration for the recommended :class:`~gmft.formatters.base.BaseFormatter`. Currently points to :class:`~gmft.formatters.tatr.TATRFormatConfig`. """ + def __new__(cls, *args, **kwargs): from gmft.impl.tatr.config import TATRFormatConfig + return TATRFormatConfig(*args, **kwargs) @@ -50,6 +55,8 @@ class AutoTableDetector: Using :meth:`~gmft.detectors.base.BaseDetector.extract` produces a :class:`~gmft.formatters.base.FormattedTable`, which can be exported to csv, df, etc. """ + def __new__(cls, *args, **kwargs): from gmft.detectors.tatr import TATRDetector + return TATRDetector(*args, **kwargs) diff --git a/gmft/core/io/__init__.py b/gmft/core/io/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/gmft/core/io/serial/dicts.py b/gmft/core/io/serial/dicts.py new file mode 100644 index 0000000..1969f1f --- /dev/null +++ b/gmft/core/io/serial/dicts.py @@ -0,0 +1,59 @@ +import copy +from typing import Optional +from gmft.core.ml.prediction import ( + IndicesPredictions, + RawBboxPredictions, + _empty_indices_predictions, +) +from gmft.detectors.base import CroppedTable +from gmft.formatters.base import _normalize_bbox +from gmft.impl.tatr.config import TATRFormatConfig +from gmft.pdf_bindings.base import BasePage + + +def _extract_fctn_results(d: dict) -> RawBboxPredictions: + """ + Extract prediction["tatr"], formerly known as fctn_results + """ + if "fctn_results" not in d: + raise ValueError( + "fctn_results not found in dict -- dict may be a CroppedTable but not a TATRFormattedTable." + ) + + results = d["fctn_results"] # fix shallow copy issue + if ( + "fctn_scale_factor" in d + or "scale_factor" in d + or "fctn_padding" in d + or "padding" in d + ): + # deprecated: this is for backwards compatibility + scale_factor = d.get("fctn_scale_factor", d.get("scale_factor", 1)) + padding = d.get("fctn_padding", d.get("padding", (0, 0))) + padding = tuple(padding) + + # normalize results here + for i, bbox in enumerate(results["boxes"]): + results["boxes"][i] = _normalize_bbox( + bbox, used_scale_factor=scale_factor, used_padding=padding + ) + return results + + +def _extract_indices(d: dict) -> IndicesPredictions: + # version gmft>=0.5 format + if "predictions.indices" in d: + return d["predictions.indices"] + + # version gmft<0.5 format + if any( + x in d + for x in ["_hier_left_indices", "_top_header_indices", "_projecting_indices"] + ): + return { + "_projecting": d.get("_projecting_indices"), + "_hier_left": d.get("_hier_left_indices"), + "_top_header": d.get("_top_header_indices"), + } + + return _empty_indices_predictions() diff --git a/gmft/core/legacy/fctn_results.py b/gmft/core/legacy/fctn_results.py new file mode 100644 index 0000000..97ccb30 --- /dev/null +++ b/gmft/core/legacy/fctn_results.py @@ -0,0 +1,104 @@ +from gmft.core.ml.prediction import ( + RawBboxPredictions, + EffectivePredictions, + TablePredictions, +) +from typing_extensions import deprecated + + +class LegacyFctnResults: + """ + Small class to re-route old + """ + + predictions: TablePredictions + + @property + @deprecated("Use self.predictions['tatr']") + def fctn_results(self) -> RawBboxPredictions: + return self.predictions["tatr"] + + @fctn_results.setter + @deprecated("Use self.predictions['tatr']") + def fctn_results(self, value: RawBboxPredictions): + self.predictions["tatr"] = value + + @property + @deprecated("Use self.predictions['effective']") + def effective_rows(self): + return self.predictions["effective"]["rows"] + + @effective_rows.setter + @deprecated("Use self.predictions['effective']") + def effective_rows(self, value): + self.predictions["effective"]["rows"] = value + + @property + @deprecated("Use self.predictions['effective']") + def effective_columns(self): + return self.predictions["effective"]["columns"] + + @effective_columns.setter + @deprecated("Use self.predictions['effective']") + def effective_columns(self, value): + self.predictions["effective"]["columns"] = value + + @property + @deprecated("Use self.predictions['effective']") + def effective_headers(self): + return self.predictions["effective"]["headers"] + + @effective_headers.setter + @deprecated("Use self.predictions['effective']") + def effective_headers(self, value): + self.predictions["effective"]["headers"] = value + + @property + @deprecated("Use self.predictions['effective']") + def effective_projecting(self): + return self.predictions["effective"]["projecting"] + + @effective_projecting.setter + @deprecated("Use self.predictions['effective']") + def effective_projecting(self, value): + self.predictions["effective"]["projecting"] = value + + @property + @deprecated("Use self.predictions['effective']") + def effective_spanning(self): + return self.predictions["effective"]["spanning"] + + @effective_spanning.setter + @deprecated("Use self.predictions['effective']") + def effective_spanning(self, value): + self.predictions["effective"]["spanning"] = value + + @property + @deprecated("Use self.predictions['indices']['top_header']") + def _top_header_indices(self): + return self.predictions["indices"].get("top_header") + + @_top_header_indices.setter + @deprecated("Use self.predictions['indices']['_top_header']") + def _top_header_indices(self, value): + self.predictions["indices"]["_top_header"] = value + + @property + @deprecated("Use self.predictions['indices']['_projecting']") + def _projecting_indices(self): + return self.predictions["indices"].get("_projecting") + + @_projecting_indices.setter + @deprecated("Use self.predictions['indices']['_projecting']") + def _projecting_indices(self, value): + self.predictions["indices"]["_projecting"] = value + + @property + @deprecated("Use self.predictions['indices']['_hier_left']") + def _hier_left_indices(self): + return self.predictions["indices"].get("_hier_left") + + @_hier_left_indices.setter + @deprecated("Use self.predictions['indices']['hier_left']") + def _hier_left_indices(self, value): + self.predictions["indices"]["hier_left"] = value diff --git a/gmft/core/ml/__init__.py b/gmft/core/ml/__init__.py index 0c2f6cd..ed06c67 100644 --- a/gmft/core/ml/__init__.py +++ b/gmft/core/ml/__init__.py @@ -1,11 +1,14 @@ from typing import TYPE_CHECKING, Literal, Union -def _resolve_device(device: Union[Literal["cpu", "cuda", "auto"], str]) -> Literal["cpu", "cuda"]: +def _resolve_device( + device: Union[Literal["cpu", "cuda", "auto"], str], +) -> Literal["cpu", "cuda"]: """ Lazy resolve the device when needed (without importing torch at the top level). """ - if device == 'auto': + if device == "auto": import torch - return 'cuda' if torch.cuda.is_available() else 'cpu' - return device \ No newline at end of file + + return "cuda" if torch.cuda.is_available() else "cpu" + return device diff --git a/gmft/core/ml/prediction/__init__.py b/gmft/core/ml/prediction/__init__.py index eed33ab..08b070f 100644 --- a/gmft/core/ml/prediction/__init__.py +++ b/gmft/core/ml/prediction/__init__.py @@ -1,23 +1,29 @@ -from typing import Tuple, TypedDict, List, Union +from typing import Optional, Tuple, TypedDict, List, Union +from typing_extensions import NotRequired + # Type definitions for predictions structure class RawBboxPredictions(TypedDict): """Type definition for a single model's bbox prediction output.""" + scores: List[float] labels: List[int] boxes: List[List[float]] + class BboxPrediction(TypedDict): confidence: float label: str bbox: Tuple[float, float, float, float] + class EffectivePredictions(TypedDict): """ Effective rows/columns/etc as seen by the image --> df algorithm. - + May be postprocessed from the table structure recognition model of choice (ie. TATR). """ + rows: List[BboxPrediction] columns: List[BboxPrediction] @@ -31,11 +37,38 @@ class EffectivePredictions(TypedDict): "Spanning cells as seen by the image --> df algorithm." +class IndicesPredictions(TypedDict): + """ + Indices of key rows/columns, such as: top header, projecting, hier_left. + """ + + _top_header: NotRequired[List[int]] + _projecting: NotRequired[List[int]] + _hier_left: NotRequired[List[int]] + + class TablePredictions(TypedDict): """Type definition for the complete predictions dictionary.""" + tatr: RawBboxPredictions - + effective: EffectivePredictions + indices: IndicesPredictions + + +def _empty_effective_predictions(): + return { + "rows": [], + "columns": [], + "headers": [], + "projecting": [], + "spanning": [], + } + + +def _empty_indices_predictions(): + return {} + # predictions: Predictions = { # "tatr": { @@ -83,4 +116,4 @@ class TablePredictions(TypedDict): # [71.12321472167969, 73.54254150390625, 797.08642578125, 244.42941284179688], # ], # } -# } \ No newline at end of file +# } diff --git a/gmft/formatters/base.py b/gmft/formatters/base.py index 0077f78..b6c809f 100644 --- a/gmft/formatters/base.py +++ b/gmft/formatters/base.py @@ -2,6 +2,7 @@ import pandas as pd +from gmft.core.ml.prediction import TablePredictions from gmft.pdf_bindings.base import BasePage from gmft.detectors.base import CroppedTable, RotatedCroppedTable @@ -14,6 +15,8 @@ class FormattedTable(RotatedCroppedTable): Warning: This class is not meant to be instantiated directly. Use a :class:`.TableFormatter` to convert a :class:`.CroppedTable` to a :class:`.FormattedTable`. """ + predictions: TablePredictions + def __init__(self, cropped_table: CroppedTable, df: pd.DataFrame = None): self._df = df diff --git a/gmft/formatters/ditr.py b/gmft/formatters/ditr.py index 461b0f5..e2df8a6 100644 --- a/gmft/formatters/ditr.py +++ b/gmft/formatters/ditr.py @@ -12,7 +12,13 @@ _ioa, get_good_between_dividers, ) +from gmft.core.io.serial.dicts import _extract_fctn_results, _extract_indices +from gmft.core.legacy.fctn_results import LegacyFctnResults from gmft.core.ml import _resolve_device +from gmft.core.ml.prediction import ( + _empty_effective_predictions, + _empty_indices_predictions, +) from gmft.detectors.base import CroppedTable, RotatedCroppedTable from gmft.impl.ditr.config import DITRFormatConfig from gmft.formatters.base import FormattedTable, TableFormatter, _normalize_bbox @@ -34,7 +40,7 @@ from transformers import DetrForObjectDetection -class DITRFormattedTable(HistogramFormattedTable): +class DITRFormattedTable(HistogramFormattedTable, LegacyFctnResults): """ FormattedTable, as seen by a Table Transformer for dividers (dubbed DITR). See :class:`.DITRTableFormatter`. @@ -54,19 +60,6 @@ class DITRFormattedTable(HistogramFormattedTable): config: DITRFormatConfig outliers: dict[str, bool] - effective_headers: list[tuple] - "Headers as seen by the image --> df algorithm." - - effective_projecting: list[tuple] - "Projected rows as seen by the image --> df algorithm." - - effective_spanning: list[tuple] - "Spanning cells as seen by the image --> df algorithm." - - _top_header_indices: list[int] = None - _projecting_indices: list[int] = None - _hier_left_indices: list[int] = None - def __init__( self, cropped_table: CroppedTable, @@ -77,7 +70,11 @@ def __init__( super(DITRFormattedTable, self).__init__( cropped_table, None, irvl_results, config=config ) - self.fctn_results = fctn_results + self.predictions = { + "tatr": fctn_results, + "effective": _empty_effective_predictions(), + "indices": _empty_indices_predictions(), + } if config is None: config = DITRFormatConfig() @@ -87,7 +84,7 @@ def __init__( def df(self, recalculate=False, config_overrides: DITRFormatConfig = None): """ Return the table as a pandas dataframe. - :param recalculate: by default, the dataframe is cached + :param recalculate: by default, the dataframe is cached. DEPRECATED: use recompute() instead. :param config_overrides: override the config settings for this call only """ if recalculate != False: @@ -113,8 +110,6 @@ def visualize(self, **kwargs): Visualize the cropped table. """ img = self.image() - # labels = self.fctn_results['labels'] - # bboxes = self.fctn_results['boxes'] tbl_width = self.width # adjust for rotations too tbl_height = self.height @@ -126,13 +121,13 @@ def visualize(self, **kwargs): for y0, y1 in self.irvl_results["row_dividers"]: bboxes.append([0, y0, tbl_width, y1]) labels.append(2) - for x0, y0, x1, y1 in self.effective_headers: + for x0, y0, x1, y1 in self.predictions["effective"]["headers"]: bboxes.append([x0, y0, x1, y1]) labels.append(3) - for x0, y0, x1, y1 in self.effective_projecting: + for x0, y0, x1, y1 in self.predictions["effective"]["headers"]: bboxes.append([x0, y0, x1, y1]) labels.append(4) - for x0, y0, x1, y1 in self.effective_spanning: + for x0, y0, x1, y1 in self.predictions["effective"]["headers"]: bboxes.append([x0, y0, x1, y1]) labels.append(5) return plot_shaded_boxes(img, labels=labels, boxes=bboxes, **kwargs) @@ -146,18 +141,14 @@ def to_dict(self): else: parent = CroppedTable.to_dict(self) optional = {} - if self._projecting_indices is not None: - optional["_projecting_indices"] = self._projecting_indices - if self._hier_left_indices is not None: - optional["_hier_left_indices"] = self._hier_left_indices - if self._top_header_indices is not None: - optional["_top_header_indices"] = self._top_header_indices + if self.predictions["indices"]: + optional["predictions.indices"] = self.predictions["indices"] return { **parent, **{ "config": non_defaults_only(self.config), "outliers": self.outliers, - "fctn_results": self.fctn_results, + "fctn_results": self.predictions["tatr"], }, **optional, } @@ -171,31 +162,10 @@ def from_dict(d: dict, page: BasePage): d = copy.deepcopy(d) # don't modify the original dict cropped_table = CroppedTable.from_dict(d, page) - if "fctn_results" not in d: - raise ValueError( - "fctn_results not found in dict -- dict may be a CroppedTable but not a TATRFormattedTable." - ) + results = _extract_fctn_results(d) config = DITRFormatConfig(**d["config"]) - results = d["fctn_results"] # fix shallow copy issue - if ( - "fctn_scale_factor" in d - or "scale_factor" in d - or "fctn_padding" in d - or "padding" in d - ): - # deprecated: this is for backwards compatibility - scale_factor = d.get("fctn_scale_factor", d.get("scale_factor", 1)) - padding = d.get("fctn_padding", d.get("padding", (0, 0))) - padding = tuple(padding) - - # normalize results here - for i, bbox in enumerate(results["boxes"]): - results["boxes"][i] = _normalize_bbox( - bbox, used_scale_factor=scale_factor, used_padding=padding - ) - table = DITRFormattedTable( cropped_table, None, @@ -204,6 +174,7 @@ def from_dict(d: dict, page: BasePage): ) table.recompute() table.outliers = d.get("outliers", None) + table.predictions["indices"] = _extract_indices(d) return table @@ -463,7 +434,7 @@ def ditr_extract_to_df(table: DITRFormattedTable, config: DITRFormatConfig = Non outliers = {} # store table-wide information about outliers or pecularities - results = table.fctn_results + results = table.predictions["tatr"] row_divider_boxes, col_divider_boxes, top_headers, projected, spanning_cells = ( proportion_fctn_results(results, config) ) @@ -491,9 +462,13 @@ def ditr_extract_to_df(table: DITRFormattedTable, config: DITRFormatConfig = Non "row_dividers": row_divider_intervals, "col_dividers": col_divider_intervals, } - table.effective_headers = top_headers - table.effective_projecting = projected - table.effective_spanning = [span["bbox"] for span in spanning_cells] + table.predictions["effective"] = { + "rows": [], + "columns": [], + "headers": top_headers, + "projecting": projected, + "spanning": [span["bbox"] for span in spanning_cells], + } # table_bounds = table.bbox # empirical_table_bbox(row_divider_boxes, col_divider_boxes) fixed_table_bounds = (0, 0, table.width, table.height) # adjust for rotations too @@ -549,6 +524,7 @@ def ditr_extract_to_df(table: DITRFormattedTable, config: DITRFormatConfig = Non projecting_indices = [i for i in projecting_indices if i not in empty_rows] # semantic spanning fill + indices_preds = {} if config.semantic_spanning_cells: # TODO probably not worth it to duplicate the code old_rows = [(None, y0, None, y1) for y0, y1 in good_row_intervals] @@ -582,15 +558,15 @@ def ditr_extract_to_df(table: DITRFormattedTable, config: DITRFormatConfig = Non header_indices=header_indices, config=config, ) - table._hier_left_indices = hier_left_idxs + indices_preds["_hier_left"] = hier_left_idxs else: - table._hier_left_indices = [] # for the user + indices_preds["_hier_left"] = [] # for the user # technically these indices will be off by the number of header rows ;-; if config.enable_multi_header: - table._top_header_indices = header_indices + indices_preds["_top_header"] = header_indices else: - table._top_header_indices = [0] if header_indices else [] + indices_preds["_top_header"] = [0] if header_indices else [] # extract out the headers header_rows = table_array[header_indices] @@ -621,8 +597,9 @@ def ditr_extract_to_df(table: DITRFormattedTable, config: DITRFormatConfig = Non # remove the header_indices # note that ditr._determine_headers_and_projecting # automatically makes is_projecting and header_indices mutually exclusive - table._projecting_indices = [i for i, x in enumerate(is_projecting) if x] + indices_preds["_projecting"] = [i for i, x in enumerate(is_projecting) if x] + table.predictions["indices"] = indices_preds # b. drop the former header rows always table._df.drop(index=header_indices, inplace=True) diff --git a/gmft/formatters/tatr.py b/gmft/formatters/tatr.py index fcb8f05..c56207c 100644 --- a/gmft/formatters/tatr.py +++ b/gmft/formatters/tatr.py @@ -1,9 +1,15 @@ import copy -from typing import Union +from typing import List, Union from gmft.core._dataclasses import non_defaults_only, with_config +from gmft.core.io.serial.dicts import _extract_fctn_results, _extract_indices +from gmft.core.legacy.fctn_results import LegacyFctnResults from gmft.core.ml import _resolve_device -from gmft.core.ml.prediction import BboxPrediction +from gmft.core.ml.prediction import ( + BboxPrediction, + _empty_effective_predictions, + _empty_indices_predictions, +) from gmft.detectors.base import CroppedTable, RotatedCroppedTable from gmft.impl.tatr.config import TATRFormatConfig from gmft.formatters.base import FormattedTable, TableFormatter, _normalize_bbox @@ -15,7 +21,7 @@ from gmft.table_visualization import plot_results_unwr -class TATRFormattedTable(FormattedTable): +class TATRFormattedTable(FormattedTable, LegacyFctnResults): """ FormattedTable, as seen by a Table Transformer (TATR). See :class:`.TATRTableFormatter`. @@ -45,25 +51,6 @@ class TATRFormattedTable(FormattedTable): config: TATRFormatConfig outliers: dict[str, bool] - effective_rows: list[BboxPrediction] - "Rows as seen by the image --> df algorithm, which may differ from what the table transformer sees." - - effective_columns: list[BboxPrediction] - "Columns as seen by the image --> df algorithm, which may differ from what the table transformer sees." - - effective_headers: list[BboxPrediction] - "Headers as seen by the image --> df algorithm." - - effective_projecting: list[BboxPrediction] - "Projected rows as seen by the image --> df algorithm." - - effective_spanning: list[BboxPrediction] - "Spanning cells as seen by the image --> df algorithm." - - _top_header_indices: list[int] = None - _projecting_indices: list[int] = None - _hier_left_indices: list[int] = None - def __init__( self, cropped_table: CroppedTable, @@ -71,7 +58,11 @@ def __init__( config: TATRFormatConfig = None, ): super(TATRFormattedTable, self).__init__(cropped_table) - self.fctn_results = fctn_results + self.predictions = { + "tatr": fctn_results, + "effective": _empty_effective_predictions(), + "indices": _empty_indices_predictions(), + } if config is None: config = TATRFormatConfig() @@ -130,13 +121,11 @@ def visualize( if effective: if self._df is None: self._df = self.df() - vis = ( - self.effective_rows - + self.effective_columns - + self.effective_headers - + self.effective_projecting - + self.effective_spanning - ) + vis: List[BboxPrediction] = [ + item + for sublist in self.predictions["effective"].values() + for item in sublist + ] boxes = [x["bbox"] for x in vis] boxes = [(x * scale_by for x in bbox) for bbox in boxes] _to_visualize = { @@ -147,12 +136,13 @@ def visualize( else: # transform functionalized coordinates into image coordinates boxes = [ - (x * scale_by for x in bbox) for bbox in self.fctn_results["boxes"] + (x * scale_by for x in bbox) + for bbox in self.predictions["tatr"]["boxes"] ] _to_visualize = { - "scores": self.fctn_results["scores"], - "labels": self.fctn_results["labels"], + "scores": self.predictions["tatr"]["scores"], + "labels": self.predictions["tatr"]["labels"], "boxes": boxes, } @@ -181,18 +171,14 @@ def to_dict(self): else: parent = CroppedTable.to_dict(self) optional = {} - if self._projecting_indices is not None: - optional["_projecting_indices"] = self._projecting_indices - if self._hier_left_indices is not None: - optional["_hier_left_indices"] = self._hier_left_indices - if self._top_header_indices is not None: - optional["_top_header_indices"] = self._top_header_indices + if self.predictions["indices"]: + optional["predictions.indices"] = self.predictions["indices"] return { **parent, **{ "config": non_defaults_only(self.config), "outliers": self.outliers, - "fctn_results": self.fctn_results, + "fctn_results": self.predictions["tatr"], }, **optional, } @@ -206,37 +192,16 @@ def from_dict(d: dict, page: BasePage): d = copy.deepcopy(d) # don't modify the original dict cropped_table = CroppedTable.from_dict(d, page) - if "fctn_results" not in d: - raise ValueError( - "fctn_results not found in dict -- dict may be a CroppedTable but not a TATRFormattedTable." - ) - + results = _extract_fctn_results(d) config = TATRFormatConfig(**d["config"]) - results = d["fctn_results"] # fix shallow copy issue - if ( - "fctn_scale_factor" in d - or "scale_factor" in d - or "fctn_padding" in d - or "padding" in d - ): - # deprecated: this is for backwards compatibility - scale_factor = d.get("fctn_scale_factor", d.get("scale_factor", 1)) - padding = d.get("fctn_padding", d.get("padding", (0, 0))) - padding = tuple(padding) - - # normalize results here - for i, bbox in enumerate(results["boxes"]): - results["boxes"][i] = _normalize_bbox( - bbox, used_scale_factor=scale_factor, used_padding=padding - ) - table = TATRFormattedTable( cropped_table, results, config=config, ) table.outliers = d.get("outliers", None) + table.predictions["indices"] = _extract_indices(d) return table diff --git a/test/formatters/ditr/test_df.py b/test/formatters/ditr/test_df.py index a4b73c9..dcb65cb 100644 --- a/test/formatters/ditr/test_df.py +++ b/test/formatters/ditr/test_df.py @@ -95,7 +95,7 @@ def test_bulk_pdf5_t0(self, pdf5_tables): pass # this one just doesn't work very well # TODO make it work based on minima # try_jth_table(pdf5_tables, 5, 0) - # assert pdf5_tables[0]._projecting_indices == [15, 18, 22, 29] + # assert pdf5_tables[0].predictions["indices"]["_projecting"] == [15, 18, 22, 29] def test_bulk_pdf5_t1(self, ditr_tables, ditr_csvs, docs_bulk): try_table("pdf5_t1", ditr_tables, ditr_csvs, docs_bulk[5 - 1]) diff --git a/test/formatters/histogram/test_df.py b/test/formatters/histogram/test_df.py index 46ab6b9..19b4d02 100644 --- a/test/formatters/histogram/test_df.py +++ b/test/formatters/histogram/test_df.py @@ -112,7 +112,7 @@ def test_bulk_pdf5_t0(self, pdf5_tables): pass # this one just doesn't work very well # TODO make it work based on minima # try_jth_table(pdf5_tables, 5, 0) - # assert pdf5_tables[0]._projecting_indices == [15, 18, 22, 29] + # assert pdf5_tables[0].predictions["indices"]["_projecting"] == [15, 18, 22, 29] def test_bulk_pdf5_t1(self, pdf5_tables, tatr_csvs): try_jth_table(pdf5_tables, tatr_csvs, 5, 1) diff --git a/test/formatters/tatr/test_df.py b/test/formatters/tatr/test_df.py index b076858..dfba1b5 100644 --- a/test/formatters/tatr/test_df.py +++ b/test/formatters/tatr/test_df.py @@ -92,11 +92,11 @@ def test_bulk_pdf2_t0(self, pdf2_tables, tatr_csvs): def test_bulk_pdf2_t1(self, pdf2_tables, tatr_csvs): try_jth_table(pdf2_tables, tatr_csvs, 2, 1) # hint: subtract 2 from the line no to get the proj. index (assume 1 header) - assert pdf2_tables[1]._projecting_indices == [9, 12, 16] + assert pdf2_tables[1].predictions["indices"]["_projecting"] == [9, 12, 16] def test_bulk_pdf2_t2(self, pdf2_tables, tatr_csvs): try_jth_table(pdf2_tables, tatr_csvs, 2, 2) - assert pdf2_tables[2]._projecting_indices == [0, 5] + assert pdf2_tables[2].predictions["indices"]["_projecting"] == [0, 5] def test_bulk_pdf2_t3(self, pdf2_tables, tatr_csvs): try_jth_table(pdf2_tables, tatr_csvs, 2, 3) @@ -112,7 +112,7 @@ def test_bulk_pdf3_t1(self, pdf3_tables, tatr_csvs): def test_bulk_pdf3_t2(self, pdf3_tables, tatr_csvs): try_jth_table(pdf3_tables, tatr_csvs, 3, 2) - assert pdf3_tables[2]._projecting_indices == [0, 8] + assert pdf3_tables[2].predictions["indices"]["_projecting"] == [0, 8] def test_bulk_pdf3_t3(self, pdf3_tables, tatr_csvs): try_jth_table(pdf3_tables, tatr_csvs, 3, 3) @@ -124,17 +124,17 @@ def test_bulk_pdf4_t0(self, pdf4_tables, tatr_csvs): def test_bulk_pdf4_t1(self, pdf4_tables, tatr_csvs): try_jth_table(pdf4_tables, tatr_csvs, 4, 1) - assert pdf4_tables[1]._projecting_indices == [0, 14] + assert pdf4_tables[1].predictions["indices"]["_projecting"] == [0, 14] class TestPdf5: def test_bulk_pdf5_t0(self, pdf5_tables, tatr_csvs): try_jth_table(pdf5_tables, tatr_csvs, 5, 0) - assert pdf5_tables[0]._projecting_indices == [15, 18, 22, 29] + assert pdf5_tables[0].predictions["indices"]["_projecting"] == [15, 18, 22, 29] def test_bulk_pdf5_t1(self, pdf5_tables, tatr_csvs): try_jth_table(pdf5_tables, tatr_csvs, 5, 1) - assert pdf5_tables[1]._projecting_indices == [13, 16, 22, 26] + assert pdf5_tables[1].predictions["indices"]["_projecting"] == [13, 16, 22, 26] class TestPdf6: diff --git a/test/formatters/tatr/test_spanning.py b/test/formatters/tatr/test_spanning.py index 03a8c1b..2d413d8 100644 --- a/test/formatters/tatr/test_spanning.py +++ b/test/formatters/tatr/test_spanning.py @@ -178,7 +178,7 @@ def test_pdf2_t2(self, pdf2_tables): try_jth_table(pdf2_tables, 2, 2, expected, config=config2) - assert pdf2_tables[2]._projecting_indices == [0, 5] + assert pdf2_tables[2].predictions["indices"]["_projecting"] == [0, 5] # pdf4 t1 is arguably HierTop, but the ground truth is not yet clear From a4dc3b70f664eddb6aac57a7183bb50b5ae55927 Mon Sep 17 00:00:00 2001 From: conjuncts <67614673+conjuncts@users.noreply.github.com> Date: Sun, 22 Jun 2025 15:14:49 -0500 Subject: [PATCH 3/7] Improve config settings --- gmft/core/reformat/__init__.py | 0 gmft/impl/ditr/config.py | 79 ++-------------------------------- gmft/impl/tatr/config.py | 3 +- test/test_auto.py | 1 - 4 files changed, 6 insertions(+), 77 deletions(-) delete mode 100644 gmft/core/reformat/__init__.py diff --git a/gmft/core/reformat/__init__.py b/gmft/core/reformat/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/gmft/impl/ditr/config.py b/gmft/impl/ditr/config.py index afbb053..815e709 100644 --- a/gmft/impl/ditr/config.py +++ b/gmft/impl/ditr/config.py @@ -4,64 +4,22 @@ from dataclasses import dataclass, field from typing import Literal, Union +from gmft.impl.tatr.config import TATRFormatConfig + @dataclass -class DITRFormatConfig(HistogramConfig): +class DITRFormatConfig(HistogramConfig, TATRFormatConfig): """ Configuration for :class:`.DITRTableFormatter`. """ # ---- model settings ---- - warn_uninitialized_weights: bool = False - image_processor_path: str = ( - "microsoft/table-transformer-structure-recognition-v1.1-all" - ) formatter_path: str = "conjuncts/ditr-e15" - # no_timm: bool = True # use a model which uses AutoBackbone. - torch_device: Union[Literal["auto", "cpu", "cuda"], str] = "auto" - - verbosity: int = 1 - """ - 0: errors only\n - 1: print warnings\n - 2: print warnings and info\n - 3: print warnings, info, and debug - """ - - formatter_base_threshold: float = 0.3 - """Base threshold for the confidence demanded of a separating line. - - Since merged rows are generally harder to deal with than empty rows, a low threshold is usually - better, because then more separating lines are detected. - """ - - cell_required_confidence: dict = field( - default_factory=lambda: { - 0: 0.3, # table - 1: 0.3, # column - 2: 0.3, # row - 3: 0.3, # column header - 4: 0.5, # projected row header - 5: 0.5, # spanning cell - 6: 99, # no object - } - ) - """Confidences required (>=) for a row/column feature to be considered good. See DITRFormattedTable.id2label - - But low confidences may be better than too high confidence (see formatter_base_threshold) - """ - - # ---- df() settings ---- - - # ---- options ---- - - remove_null_rows: bool = True - """Remove rows with no text.""" enable_multi_header: bool = True """Enable multi-indices in the dataframe. - If false, then multiple headers will be merged column-wise.""" + If false, then multiple headers will be merged vertically.""" semantic_spanning_cells: bool = True """ @@ -102,9 +60,6 @@ def large_table_row_overlap_threshold(self): def force_large_table_assumption(self): pass - large_table_maximum_rows: int = 1000 - """If the table predicts a large number of rows, refuse to proceed. Therefore prevent memory issues for super small text.""" - # ---- rejection and warnings ---- # note that the overlap metric is not useful anymore since separating lines are not @@ -134,34 +89,8 @@ def iob_warn_threshold(self): # ---- technical ---- - _nms_overlap_threshold: float = 0.1 _nms_overlap_threshold_larger: float = 0.5 @removed_property("Large table approach ({name}) is not used for the DITR model.") def _large_table_merge_distance(self): pass - - _smallest_supported_text_height: float = 0.1 - """The smallest supported text height. Text smaller than this height will be ignored. - Helps prevent very small text from creating huge arrays under large table assumption.""" - - # ---- deprecated ---- - # aggregate_spanning_cells = False - @removed_property - def aggregate_spanning_cells(self): - pass - - # corner_clip_outlier_threshold = 0.1 - # """"corner clip" is when the text is clipped by a corner, and not an edge""" - @removed_property - def corner_clip_outlier_threshold(self): - pass - - # spanning_cell_minimum_width = 0.6 - @removed_property - def spanning_cell_minimum_width(self): - pass - - @property - def deduplication_iob_threshold(self): - pass diff --git a/gmft/impl/tatr/config.py b/gmft/impl/tatr/config.py index f55d92a..15bd125 100644 --- a/gmft/impl/tatr/config.py +++ b/gmft/impl/tatr/config.py @@ -3,6 +3,7 @@ from dataclasses import dataclass, field from typing import Literal, Union +from typing_extensions import deprecated @dataclass @@ -61,7 +62,7 @@ class TATRFormatConfig: enable_multi_header: bool = False """Enable multi-indices in the dataframe. - If false, then multiple headers will be merged column-wise.""" + If false, then multiple headers will be merged vertically.""" semantic_spanning_cells: bool = False """ diff --git a/test/test_auto.py b/test/test_auto.py index 2027b94..c0268b0 100644 --- a/test/test_auto.py +++ b/test/test_auto.py @@ -27,4 +27,3 @@ def test_auto_format_config_instantiation(): """Test that AutoFormatConfig properly instantiates as TATRFormatConfig.""" config = AutoFormatConfig() assert isinstance(config, TATRFormatConfig) - From 712f19ead8cf419509b5dd42e8d99762a5b48c78 Mon Sep 17 00:00:00 2001 From: conjuncts <67614673+conjuncts@users.noreply.github.com> Date: Sun, 22 Jun 2025 15:47:58 -0500 Subject: [PATCH 4/7] Add test for ft visualize --- data/test/references/img/pdf2_t2.png | Bin 0 -> 80152 bytes test/formatters/tatr/test_visualize.py | 96 +++++++++++++++++++++++++ 2 files changed, 96 insertions(+) create mode 100644 data/test/references/img/pdf2_t2.png create mode 100644 test/formatters/tatr/test_visualize.py diff --git a/data/test/references/img/pdf2_t2.png b/data/test/references/img/pdf2_t2.png new file mode 100644 index 0000000000000000000000000000000000000000..87921855039811c9eb7d87b12a389ace95711a5d GIT binary patch literal 80152 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%0AI|4}hWWa+$|^0@JyUOV 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 sZ76i%4}!YWI=<1@M#q=>Px# literal 0 HcmV?d00001 diff --git a/test/formatters/tatr/test_visualize.py b/test/formatters/tatr/test_visualize.py new file mode 100644 index 0000000..4b5346f --- /dev/null +++ b/test/formatters/tatr/test_visualize.py @@ -0,0 +1,96 @@ +import numpy as np +from PIL import Image +import pytest +from gmft.formatters.tatr import TATRFormattedTable +from gmft.detectors.base import CroppedTable +from gmft.impl.tatr.config import TATRFormatConfig + + +# def test_tatr_formatted_table_visualize_minimal(): +# # Create a minimal synthetic CroppedTable +# class DummyCroppedTable(CroppedTable): +# def __init__(self): +# self._img_dpi = 72 +# self._img_padding = (0, 0) +# self._img_margin = (0, 0, 0, 0) +# self.angle = 0 +# self._df = None +# self.predictions = {} +# self.image_shape = (100, 100, 3) + +# def image(self, dpi=None, padding=None, margin=None): +# # Return a blank white image +# arr = np.ones(self.image_shape, dtype=np.uint8) * 255 +# return Image.fromarray(arr) + +# cropped_table = DummyCroppedTable() +# # Minimal fctn_results with one box +# fctn_results = { +# "boxes": [[10, 10, 50, 50]], +# "scores": [0.99], +# "labels": [0], +# } +# config = TATRFormatConfig() +# tft = TATRFormattedTable(cropped_table, fctn_results, config=config) +# # Should return a PIL Image +# img = tft.visualize(return_img=True) +# assert isinstance(img, Image.Image) +# # Should not raise for effective=True +# img2 = tft.visualize(effective=True, return_img=True) +# assert isinstance(img2, Image.Image) + + +def images_distance(img1: Image.Image, img2: Image.Image) -> float: + """ + Compares two PIL images for visual similarity; returns a distance. + A lower value means more similar. + + Args: + img1 (Image.Image): First image. + img2 (Image.Image): Second image. + + Returns: + float: The mean pixelwise difference between the images. Between 0 and 255. + """ + # Convert both images to RGB + img1 = img1.convert("RGB") + img2 = img2.convert("RGB") + + # Check size + if img1.size != img2.size: + return float("inf") + + # Convert to NumPy arrays + arr1 = np.array(img1).astype(np.int16) + arr2 = np.array(img2).astype(np.int16) + + # Compute absolute difference + diff = np.abs(arr1 - arr2) + + # Compute mean difference + return np.mean(diff) + + +def test_visualize_content(pdf2_tables): + """ + Tests that the output of visualize() is consistent with a reference image. + """ + ft = pdf2_tables[2] + + # Generate the image from the table + generated_img = ft.visualize(effective=True, show_labels=False, return_img=True) + + # Load reference image + reference_path = "data/test/references/img/pdf2_t2.png" + try: + reference_img = Image.open(reference_path) + except FileNotFoundError: + pytest.skip(f"Reference image not found at {reference_path}") + + # Compare images + distance = images_distance(generated_img, reference_img) + + # Allow for minor rendering differences. + # A value of 1.0 means on average each channel of each pixel is off by 1. + # print("Distance", distance) + assert distance < 1.0 # 0.0 From 8bcaca45ed9a49a168b73ae74c1719069f079c50 Mon Sep 17 00:00:00 2001 From: conjuncts <67614673+conjuncts@users.noreply.github.com> Date: Sun, 22 Jun 2025 17:22:43 -0500 Subject: [PATCH 5/7] Move angle into CroppedTable, pytest-cov --- .gitignore | 5 +- gmft/detectors/base.py | 180 ++++++++++-------------- pyproject.toml | 1 + test/scripts/script_generate_cropped.py | 21 +++ test/test_cropped.py | 23 ++- uv.lock | 95 +++++++++++++ 6 files changed, 209 insertions(+), 116 deletions(-) create mode 100644 test/scripts/script_generate_cropped.py diff --git a/.gitignore b/.gitignore index e849813..189cb88 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,5 @@ __pycache__ -legacy +./legacy dist .vscode .pytest_cache @@ -14,3 +14,6 @@ support_arena/* data/test/outputs experiments TODO.md +.coverage +coverage.xml + diff --git a/gmft/detectors/base.py b/gmft/detectors/base.py index e5f57ee..ef7b29e 100644 --- a/gmft/detectors/base.py +++ b/gmft/detectors/base.py @@ -67,6 +67,8 @@ def __init__( bbox: Union[tuple[int, int, int, int], Rect], confidence_score: float = 1.0, label=0, + *, + angle: Literal[0, 90, 180, 270] = 0, ): """ Construct a CroppedTable object. @@ -93,6 +95,10 @@ def __init__( self._word_height = None self._captions = None + self.angle = angle + if angle not in [0, 90, 180, 270]: + raise ValueError("Only 0, 90, 180, 270 are supported.") + def image( self, dpi: int = None, @@ -138,10 +144,15 @@ def image( img = self.page.get_image(dpi=dpi, rect=rect) if padding is not None: img = PIL.ImageOps.expand(img, padding, fill="white") + + if self.angle != 0: + # rotate by negative angle to get back to original orientation + img = img.rotate(-self.angle, expand=True) self._img = img self._img_dpi = dpi self._img_padding = padding self._img_margin = margin + return self._img def text_positions( @@ -152,24 +163,52 @@ def text_positions( Any words that intersect the table are captured, even if they are not fully contained. - :param remove_table_offset: if True, the positions are adjusted to be relative to the top-left corner of the table. + :param remove_table_offset: if True, the coordinates are transformed (rotated and translated) so that the top-left corner of the table is (0, 0) and the bottom-right corner is (width, height). + If False, transforms (including rotation) are ignored and original coordinates are returned. :param outside: if True, returns the **complement** of the table: all the text positions outside the table. - By default, it returns the text positions inside the table. + (default: False) :return: list of text positions, which is a tuple ``(x0, y0, x1, y1, "string")`` """ - for w in self.page.get_positions_and_text(): - if Rect(w[:4]).is_intersecting(self.rect) != outside: - if remove_table_offset: - yield ( - w[0] - self.rect.xmin, - w[1] - self.rect.ymin, - w[2] - self.rect.xmin, - w[3] - self.rect.ymin, - w[4], - ) - else: - yield w + + def _old_generator(remove_table_offset, outside): + for w in self.page.get_positions_and_text(): + if Rect(w[:4]).is_intersecting(self.rect) != outside: + if remove_table_offset: + yield ( + w[0] - self.rect.xmin, + w[1] - self.rect.ymin, + w[2] - self.rect.xmin, + w[3] - self.rect.ymin, + w[4], + ) + else: + yield w + + if self.angle == 0 or remove_table_offset == False: + yield from _old_generator( + remove_table_offset=remove_table_offset, outside=outside + ) + elif self.angle == 90: + for w in _old_generator(remove_table_offset=True, outside=outside): + x0, y0, x1, y1, text = w + x0, y0, x1, y1 = self.rect.height - y1, x0, self.rect.height - y0, x1 + yield (x0, y0, x1, y1, text) + elif self.angle == 180: + for w in _old_generator(remove_table_offset=True, outside=outside): + x0, y0, x1, y1, text = w + x0, y0, x1, y1 = ( + self.rect.width - x1, + self.rect.height - y1, + self.rect.width - x0, + self.rect.height - y0, + ) + yield (x0, y0, x1, y1, text) + elif self.angle == 270: + for w in _old_generator(remove_table_offset=True, outside=outside): + x0, y0, x1, y1, text = w + x0, y0, x1, y1 = y0, self.rect.width - x1, y1, self.rect.width - x0 + yield (x0, y0, x1, y1, text) def text(self): """ @@ -269,6 +308,8 @@ def to_dict(self): "confidence_score": self.confidence_score, "label": self.label, } + if self.angle != 0: + obj["angle"] = self.angle return obj @staticmethod @@ -297,10 +338,14 @@ def from_dict( :param page: BasePage :return: CroppedTable object """ - if "angle" in d: + if "angle" in d and d["angle"] != 0: return RotatedCroppedTable.from_dict(d, page) table = CroppedTable( - page, d["bbox"], d.get("confidence_score", 1.0), d.get("label", 0) + page, + d["bbox"], + d.get("confidence_score", 1.0), + label=d.get("label", 0), + angle=d.get("angle", 0), ) table._captions = d.get("captions", []) return table @@ -327,10 +372,14 @@ def bbox(self): @property def width(self): + if self.angle == 90 or self.angle == 270: + return self.rect.height return self.rect.width @property def height(self): + if self.angle == 90 or self.angle == 270: + return self.rect.width return self.rect.height @@ -374,7 +423,10 @@ class RotatedCroppedTable(CroppedTable): Currently, only 0, 90, 180, and 270 degree rotations are supported. An angle of 90 would mean that a 90 degree cc rotation has been applied to a level image. - In practice, the majority of rotated tables are rotated by 90 degrees. + In practice, most rotated tables are rotated by 90 degrees. + + Note: after v0.5, this class is nearly identical to CroppedTable. `angle` is now directly availble in CroppedTable. + """ def __init__( @@ -385,84 +437,8 @@ def __init__( angle: float, label=0, ): - """ - Currently, only 0, 90, 180, and 270 degree rotations are supported. - - :param page: BasePage - :param angle: angle in degrees, counterclockwise. - That is, 90 would mean that a 90 degree cc rotation has been applied to a level image. - In practice, the majority of rotated tables are rotated by 90 degrees. - - """ - super().__init__(page, bbox, confidence_score, label) - - if angle not in [0, 90, 180, 270]: - raise ValueError("Only 0, 90, 180, 270 are supported.") - self.angle = angle - - def image( - self, - dpi: int = None, - padding: Union[tuple[int, int, int, int], Literal["auto", None]] = None, - margin: Union[tuple[int, int, int, int], Literal["auto", None]] = None, - **kwargs, - ) -> PILImage: - """ - Return the image of the cropped table. - - """ - img = super().image(dpi=dpi, padding=padding, margin=margin, **kwargs) - # if self.angle == 90: - if self.angle != 0: - # rotate by negative angle to get back to original orientation - img = img.rotate(-self.angle, expand=True) - - return img - - def text_positions( - self, remove_table_offset: bool = False, outside: bool = False - ) -> Generator[tuple[int, int, int, int, str], None, None]: - """ - Return the text positions of the cropped table. - - If remove_table_offset is False, positions are relative to the top-left corner of the pdf (no adjustment for rotation). - - If remove_table_offset is True, positions are relative to a hypothetical pdf where the text in the table is perfectly level, and - pdf's top-left corner is also the table's top-left corner (both at 0, 0). - - :param remove_table_offset: if True, the positions are adjusted to be relative to the top-left corner of the table. - :param outside: if True, returns the **complement** of the table: all the text positions outside the table. - :return: list of text positions, which are tuples of (xmin, ymin, xmax, ymax, "string") - """ - if self.angle == 0 or remove_table_offset == False: - yield from super().text_positions( - remove_table_offset=remove_table_offset, outside=outside - ) - elif self.angle == 90: - for w in super().text_positions(remove_table_offset=True, outside=outside): - x0, y0, x1, y1, text = w - x0, y0, x1, y1 = self.rect.height - y1, x0, self.rect.height - y0, x1 - yield (x0, y0, x1, y1, text) - elif self.angle == 180: - for w in super().text_positions(remove_table_offset=True, outside=outside): - x0, y0, x1, y1, text = w - x0, y0, x1, y1 = ( - self.rect.width - x1, - self.rect.height - y1, - self.rect.width - x0, - self.rect.height - y0, - ) - yield (x0, y0, x1, y1, text) - elif self.angle == 270: - for w in super().text_positions(remove_table_offset=True, outside=outside): - x0, y0, x1, y1, text = w - x0, y0, x1, y1 = y0, self.rect.width - x1, y1, self.rect.width - x0 - yield (x0, y0, x1, y1, text) - - def to_dict(self): - d = super().to_dict() - d["angle"] = self.angle - return d + # NOTE: angle and label are permuted (historical artifact) + super().__init__(page, bbox, confidence_score, label, angle=angle) @staticmethod def from_dict( @@ -474,19 +450,7 @@ def from_dict( if "angle" not in d: return CroppedTable.from_dict(d, page) table = RotatedCroppedTable( - page, d["bbox"], d["confidence_score"], d["angle"], d["label"] + page, d["bbox"], d["confidence_score"], angle=d["angle"], label=d["label"] ) table._captions = d.get("captions", []) return table - - @property - def width(self): - if self.angle == 90 or self.angle == 270: - return self.rect.height - return self.rect.width - - @property - def height(self): - if self.angle == 90 or self.angle == 270: - return self.rect.width - return self.rect.height diff --git a/pyproject.toml b/pyproject.toml index 35b0c13..835179c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -65,6 +65,7 @@ ignore = ["E712"] [dependency-groups] dev = [ "pytest>=8.3.5", + "pytest-cov>=6.2.1", "ruff>=0.11.11", ] docs = [ diff --git a/test/scripts/script_generate_cropped.py b/test/scripts/script_generate_cropped.py new file mode 100644 index 0000000..b6897fa --- /dev/null +++ b/test/scripts/script_generate_cropped.py @@ -0,0 +1,21 @@ +from gmft.detectors.base import CroppedTable +from gmft.pdf_bindings.pdfium import PyPDFium2Document + + +def generate_cropped_positions_tsv(): + page = PyPDFium2Document("data/pdfs/tiny.pdf")[0] + table = CroppedTable.from_dict( + { + "filename": "data/pdfs/tiny.pdf", + "page_no": 0, + "bbox": (10, 10, 300, 150), + "confidence_score": 0.9, + "label": 0, + }, + page, + ) + + # create the tsv + with open("data/test/references/tiny_cropped_positions.tsv", "w") as f: + for pos in table.text_positions(): + f.write("\t".join(map(str, pos)) + "\n") diff --git a/test/test_cropped.py b/test/test_cropped.py index 0a81d88..8f85618 100644 --- a/test/test_cropped.py +++ b/test/test_cropped.py @@ -34,6 +34,7 @@ def test_CroppedTable_positions(doc_tiny): }, page, ) + assert not isinstance(table, RotatedCroppedTable) # get reference positions from tiny_pdfium.txt with open("data/test/references/tiny_cropped_positions.tsv") as f: @@ -152,20 +153,28 @@ def test_RotatedCroppedTable_text(doc_tiny): ) -if __name__ == "__main__": - page = PyPDFium2Document("data/pdfs/tiny.pdf")[0] +def test_CroppedTable_angle(doc_tiny): + # Reflect the fact that 'angle' has been absorbed into CroppedTable + page = doc_tiny[0] table = CroppedTable.from_dict( { "filename": "data/pdfs/tiny.pdf", "page_no": 0, - "bbox": (10, 10, 300, 150), + "bbox": (10, 12, 300, 150), "confidence_score": 0.9, "label": 0, + "angle": 0, }, page, ) + assert not isinstance(table, RotatedCroppedTable) + + with pytest.raises(ValueError, match="Only 0, 90, 180, 270 are supported."): + _ = CroppedTable(page, (1, 2, 3, 4), angle=42) + - # create the tsv - with open("data/test/references/tiny_cropped_positions.tsv", "w") as f: - for pos in table.text_positions(): - f.write("\t".join(map(str, pos)) + "\n") +# TODO: ct.image() with margin='auto', +# ct.image() with rotated image, +# text_positions with angle==[180,270] +# ct.visualize(), +# ct.from_image_only() diff --git a/uv.lock b/uv.lock index 71953a0..e74ad0c 100644 --- a/uv.lock +++ b/uv.lock @@ -322,6 +322,85 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/87/68/7f46fb537958e87427d98a4074bcde4b67a70b04900cfc5ce29bc2f556c1/contourpy-1.3.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:8c5acb8dddb0752bf252e01a3035b21443158910ac16a3b0d20e7fed7d534ce5", size = 221791 }, ] +[[package]] +name = "coverage" +version = "7.9.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/e0/98670a80884f64578f0c22cd70c5e81a6e07b08167721c7487b4d70a7ca0/coverage-7.9.1.tar.gz", hash = "sha256:6cf43c78c4282708a28e466316935ec7489a9c487518a77fa68f716c67909cec", size = 813650 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/78/1c1c5ec58f16817c09cbacb39783c3655d54a221b6552f47ff5ac9297603/coverage-7.9.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:cc94d7c5e8423920787c33d811c0be67b7be83c705f001f7180c7b186dcf10ca", size = 212028 }, + { url = "https://files.pythonhosted.org/packages/98/db/e91b9076f3a888e3b4ad7972ea3842297a52cc52e73fd1e529856e473510/coverage-7.9.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:16aa0830d0c08a2c40c264cef801db8bc4fc0e1892782e45bcacbd5889270509", size = 212420 }, + { url = "https://files.pythonhosted.org/packages/0e/d0/2b3733412954576b0aea0a16c3b6b8fbe95eb975d8bfa10b07359ead4252/coverage-7.9.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cf95981b126f23db63e9dbe4cf65bd71f9a6305696fa5e2262693bc4e2183f5b", size = 241529 }, + { url = "https://files.pythonhosted.org/packages/b3/00/5e2e5ae2e750a872226a68e984d4d3f3563cb01d1afb449a17aa819bc2c4/coverage-7.9.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f05031cf21699785cd47cb7485f67df619e7bcdae38e0fde40d23d3d0210d3c3", size = 239403 }, + { url = "https://files.pythonhosted.org/packages/37/3b/a2c27736035156b0a7c20683afe7df498480c0dfdf503b8c878a21b6d7fb/coverage-7.9.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bb4fbcab8764dc072cb651a4bcda4d11fb5658a1d8d68842a862a6610bd8cfa3", size = 240548 }, + { url = "https://files.pythonhosted.org/packages/98/f5/13d5fc074c3c0e0dc80422d9535814abf190f1254d7c3451590dc4f8b18c/coverage-7.9.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0f16649a7330ec307942ed27d06ee7e7a38417144620bb3d6e9a18ded8a2d3e5", size = 240459 }, + { url = "https://files.pythonhosted.org/packages/36/24/24b9676ea06102df824c4a56ffd13dc9da7904478db519efa877d16527d5/coverage-7.9.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:cea0a27a89e6432705fffc178064503508e3c0184b4f061700e771a09de58187", size = 239128 }, + { url = "https://files.pythonhosted.org/packages/be/05/242b7a7d491b369ac5fee7908a6e5ba42b3030450f3ad62c645b40c23e0e/coverage-7.9.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e980b53a959fa53b6f05343afbd1e6f44a23ed6c23c4b4c56c6662bbb40c82ce", size = 239402 }, + { url = "https://files.pythonhosted.org/packages/73/e0/4de7f87192fa65c9c8fbaeb75507e124f82396b71de1797da5602898be32/coverage-7.9.1-cp310-cp310-win32.whl", hash = "sha256:70760b4c5560be6ca70d11f8988ee6542b003f982b32f83d5ac0b72476607b70", size = 214518 }, + { url = "https://files.pythonhosted.org/packages/d5/ab/5e4e2fe458907d2a65fab62c773671cfc5ac704f1e7a9ddd91996f66e3c2/coverage-7.9.1-cp310-cp310-win_amd64.whl", hash = "sha256:a66e8f628b71f78c0e0342003d53b53101ba4e00ea8dabb799d9dba0abbbcebe", size = 215436 }, + { url = "https://files.pythonhosted.org/packages/60/34/fa69372a07d0903a78ac103422ad34db72281c9fc625eba94ac1185da66f/coverage-7.9.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:95c765060e65c692da2d2f51a9499c5e9f5cf5453aeaf1420e3fc847cc060582", size = 212146 }, + { url = "https://files.pythonhosted.org/packages/27/f0/da1894915d2767f093f081c42afeba18e760f12fdd7a2f4acbe00564d767/coverage-7.9.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ba383dc6afd5ec5b7a0d0c23d38895db0e15bcba7fb0fa8901f245267ac30d86", size = 212536 }, + { url = "https://files.pythonhosted.org/packages/10/d5/3fc33b06e41e390f88eef111226a24e4504d216ab8e5d1a7089aa5a3c87a/coverage-7.9.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:37ae0383f13cbdcf1e5e7014489b0d71cc0106458878ccde52e8a12ced4298ed", size = 245092 }, + { url = "https://files.pythonhosted.org/packages/0a/39/7aa901c14977aba637b78e95800edf77f29f5a380d29768c5b66f258305b/coverage-7.9.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:69aa417a030bf11ec46149636314c24c8d60fadb12fc0ee8f10fda0d918c879d", size = 242806 }, + { url = "https://files.pythonhosted.org/packages/43/fc/30e5cfeaf560b1fc1989227adedc11019ce4bb7cce59d65db34fe0c2d963/coverage-7.9.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0a4be2a28656afe279b34d4f91c3e26eccf2f85500d4a4ff0b1f8b54bf807338", size = 244610 }, + { url = "https://files.pythonhosted.org/packages/bf/15/cca62b13f39650bc87b2b92bb03bce7f0e79dd0bf2c7529e9fc7393e4d60/coverage-7.9.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:382e7ddd5289f140259b610e5f5c58f713d025cb2f66d0eb17e68d0a94278875", size = 244257 }, + { url = "https://files.pythonhosted.org/packages/cd/1a/c0f2abe92c29e1464dbd0ff9d56cb6c88ae2b9e21becdb38bea31fcb2f6c/coverage-7.9.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e5532482344186c543c37bfad0ee6069e8ae4fc38d073b8bc836fc8f03c9e250", size = 242309 }, + { url = "https://files.pythonhosted.org/packages/57/8d/c6fd70848bd9bf88fa90df2af5636589a8126d2170f3aade21ed53f2b67a/coverage-7.9.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a39d18b3f50cc121d0ce3838d32d58bd1d15dab89c910358ebefc3665712256c", size = 242898 }, + { url = "https://files.pythonhosted.org/packages/c2/9e/6ca46c7bff4675f09a66fe2797cd1ad6a24f14c9c7c3b3ebe0470a6e30b8/coverage-7.9.1-cp311-cp311-win32.whl", hash = "sha256:dd24bd8d77c98557880def750782df77ab2b6885a18483dc8588792247174b32", size = 214561 }, + { url = "https://files.pythonhosted.org/packages/a1/30/166978c6302010742dabcdc425fa0f938fa5a800908e39aff37a7a876a13/coverage-7.9.1-cp311-cp311-win_amd64.whl", hash = "sha256:6b55ad10a35a21b8015eabddc9ba31eb590f54adc9cd39bcf09ff5349fd52125", size = 215493 }, + { url = "https://files.pythonhosted.org/packages/60/07/a6d2342cd80a5be9f0eeab115bc5ebb3917b4a64c2953534273cf9bc7ae6/coverage-7.9.1-cp311-cp311-win_arm64.whl", hash = "sha256:6ad935f0016be24c0e97fc8c40c465f9c4b85cbbe6eac48934c0dc4d2568321e", size = 213869 }, + { url = "https://files.pythonhosted.org/packages/68/d9/7f66eb0a8f2fce222de7bdc2046ec41cb31fe33fb55a330037833fb88afc/coverage-7.9.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a8de12b4b87c20de895f10567639c0797b621b22897b0af3ce4b4e204a743626", size = 212336 }, + { url = "https://files.pythonhosted.org/packages/20/20/e07cb920ef3addf20f052ee3d54906e57407b6aeee3227a9c91eea38a665/coverage-7.9.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5add197315a054e92cee1b5f686a2bcba60c4c3e66ee3de77ace6c867bdee7cb", size = 212571 }, + { url = "https://files.pythonhosted.org/packages/78/f8/96f155de7e9e248ca9c8ff1a40a521d944ba48bec65352da9be2463745bf/coverage-7.9.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:600a1d4106fe66f41e5d0136dfbc68fe7200a5cbe85610ddf094f8f22e1b0300", size = 246377 }, + { url = "https://files.pythonhosted.org/packages/3e/cf/1d783bd05b7bca5c10ded5f946068909372e94615a4416afadfe3f63492d/coverage-7.9.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2a876e4c3e5a2a1715a6608906aa5a2e0475b9c0f68343c2ada98110512ab1d8", size = 243394 }, + { url = "https://files.pythonhosted.org/packages/02/dd/e7b20afd35b0a1abea09fb3998e1abc9f9bd953bee548f235aebd2b11401/coverage-7.9.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:81f34346dd63010453922c8e628a52ea2d2ccd73cb2487f7700ac531b247c8a5", size = 245586 }, + { url = "https://files.pythonhosted.org/packages/4e/38/b30b0006fea9d617d1cb8e43b1bc9a96af11eff42b87eb8c716cf4d37469/coverage-7.9.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:888f8eee13f2377ce86d44f338968eedec3291876b0b8a7289247ba52cb984cd", size = 245396 }, + { url = "https://files.pythonhosted.org/packages/31/e4/4d8ec1dc826e16791f3daf1b50943e8e7e1eb70e8efa7abb03936ff48418/coverage-7.9.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:9969ef1e69b8c8e1e70d591f91bbc37fc9a3621e447525d1602801a24ceda898", size = 243577 }, + { url = "https://files.pythonhosted.org/packages/25/f4/b0e96c5c38e6e40ef465c4bc7f138863e2909c00e54a331da335faf0d81a/coverage-7.9.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:60c458224331ee3f1a5b472773e4a085cc27a86a0b48205409d364272d67140d", size = 244809 }, + { url = "https://files.pythonhosted.org/packages/8a/65/27e0a1fa5e2e5079bdca4521be2f5dabf516f94e29a0defed35ac2382eb2/coverage-7.9.1-cp312-cp312-win32.whl", hash = "sha256:5f646a99a8c2b3ff4c6a6e081f78fad0dde275cd59f8f49dc4eab2e394332e74", size = 214724 }, + { url = "https://files.pythonhosted.org/packages/9b/a8/d5b128633fd1a5e0401a4160d02fa15986209a9e47717174f99dc2f7166d/coverage-7.9.1-cp312-cp312-win_amd64.whl", hash = "sha256:30f445f85c353090b83e552dcbbdad3ec84c7967e108c3ae54556ca69955563e", size = 215535 }, + { url = "https://files.pythonhosted.org/packages/a3/37/84bba9d2afabc3611f3e4325ee2c6a47cd449b580d4a606b240ce5a6f9bf/coverage-7.9.1-cp312-cp312-win_arm64.whl", hash = "sha256:af41da5dca398d3474129c58cb2b106a5d93bbb196be0d307ac82311ca234342", size = 213904 }, + { url = "https://files.pythonhosted.org/packages/d0/a7/a027970c991ca90f24e968999f7d509332daf6b8c3533d68633930aaebac/coverage-7.9.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:31324f18d5969feef7344a932c32428a2d1a3e50b15a6404e97cba1cc9b2c631", size = 212358 }, + { url = "https://files.pythonhosted.org/packages/f2/48/6aaed3651ae83b231556750280682528fea8ac7f1232834573472d83e459/coverage-7.9.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0c804506d624e8a20fb3108764c52e0eef664e29d21692afa375e0dd98dc384f", size = 212620 }, + { url = "https://files.pythonhosted.org/packages/6c/2a/f4b613f3b44d8b9f144847c89151992b2b6b79cbc506dee89ad0c35f209d/coverage-7.9.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ef64c27bc40189f36fcc50c3fb8f16ccda73b6a0b80d9bd6e6ce4cffcd810bbd", size = 245788 }, + { url = "https://files.pythonhosted.org/packages/04/d2/de4fdc03af5e4e035ef420ed26a703c6ad3d7a07aff2e959eb84e3b19ca8/coverage-7.9.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d4fe2348cc6ec372e25adec0219ee2334a68d2f5222e0cba9c0d613394e12d86", size = 243001 }, + { url = "https://files.pythonhosted.org/packages/f5/e8/eed18aa5583b0423ab7f04e34659e51101135c41cd1dcb33ac1d7013a6d6/coverage-7.9.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:34ed2186fe52fcc24d4561041979a0dec69adae7bce2ae8d1c49eace13e55c43", size = 244985 }, + { url = "https://files.pythonhosted.org/packages/17/f8/ae9e5cce8885728c934eaa58ebfa8281d488ef2afa81c3dbc8ee9e6d80db/coverage-7.9.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:25308bd3d00d5eedd5ae7d4357161f4df743e3c0240fa773ee1b0f75e6c7c0f1", size = 245152 }, + { url = "https://files.pythonhosted.org/packages/5a/c8/272c01ae792bb3af9b30fac14d71d63371db227980682836ec388e2c57c0/coverage-7.9.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:73e9439310f65d55a5a1e0564b48e34f5369bee943d72c88378f2d576f5a5751", size = 243123 }, + { url = "https://files.pythonhosted.org/packages/8c/d0/2819a1e3086143c094ab446e3bdf07138527a7b88cb235c488e78150ba7a/coverage-7.9.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:37ab6be0859141b53aa89412a82454b482c81cf750de4f29223d52268a86de67", size = 244506 }, + { url = "https://files.pythonhosted.org/packages/8b/4e/9f6117b89152df7b6112f65c7a4ed1f2f5ec8e60c4be8f351d91e7acc848/coverage-7.9.1-cp313-cp313-win32.whl", hash = "sha256:64bdd969456e2d02a8b08aa047a92d269c7ac1f47e0c977675d550c9a0863643", size = 214766 }, + { url = "https://files.pythonhosted.org/packages/27/0f/4b59f7c93b52c2c4ce7387c5a4e135e49891bb3b7408dcc98fe44033bbe0/coverage-7.9.1-cp313-cp313-win_amd64.whl", hash = "sha256:be9e3f68ca9edb897c2184ad0eee815c635565dbe7a0e7e814dc1f7cbab92c0a", size = 215568 }, + { url = "https://files.pythonhosted.org/packages/09/1e/9679826336f8c67b9c39a359352882b24a8a7aee48d4c9cad08d38d7510f/coverage-7.9.1-cp313-cp313-win_arm64.whl", hash = "sha256:1c503289ffef1d5105d91bbb4d62cbe4b14bec4d13ca225f9c73cde9bb46207d", size = 213939 }, + { url = "https://files.pythonhosted.org/packages/bb/5b/5c6b4e7a407359a2e3b27bf9c8a7b658127975def62077d441b93a30dbe8/coverage-7.9.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0b3496922cb5f4215bf5caaef4cf12364a26b0be82e9ed6d050f3352cf2d7ef0", size = 213079 }, + { url = "https://files.pythonhosted.org/packages/a2/22/1e2e07279fd2fd97ae26c01cc2186e2258850e9ec125ae87184225662e89/coverage-7.9.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:9565c3ab1c93310569ec0d86b017f128f027cab0b622b7af288696d7ed43a16d", size = 213299 }, + { url = "https://files.pythonhosted.org/packages/14/c0/4c5125a4b69d66b8c85986d3321520f628756cf524af810baab0790c7647/coverage-7.9.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2241ad5dbf79ae1d9c08fe52b36d03ca122fb9ac6bca0f34439e99f8327ac89f", size = 256535 }, + { url = "https://files.pythonhosted.org/packages/81/8b/e36a04889dda9960be4263e95e777e7b46f1bb4fc32202612c130a20c4da/coverage-7.9.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3bb5838701ca68b10ebc0937dbd0eb81974bac54447c55cd58dea5bca8451029", size = 252756 }, + { url = "https://files.pythonhosted.org/packages/98/82/be04eff8083a09a4622ecd0e1f31a2c563dbea3ed848069e7b0445043a70/coverage-7.9.1-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b30a25f814591a8c0c5372c11ac8967f669b97444c47fd794926e175c4047ece", size = 254912 }, + { url = "https://files.pythonhosted.org/packages/0f/25/c26610a2c7f018508a5ab958e5b3202d900422cf7cdca7670b6b8ca4e8df/coverage-7.9.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2d04b16a6062516df97969f1ae7efd0de9c31eb6ebdceaa0d213b21c0ca1a683", size = 256144 }, + { url = "https://files.pythonhosted.org/packages/c5/8b/fb9425c4684066c79e863f1e6e7ecebb49e3a64d9f7f7860ef1688c56f4a/coverage-7.9.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:7931b9e249edefb07cd6ae10c702788546341d5fe44db5b6108a25da4dca513f", size = 254257 }, + { url = "https://files.pythonhosted.org/packages/93/df/27b882f54157fc1131e0e215b0da3b8d608d9b8ef79a045280118a8f98fe/coverage-7.9.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:52e92b01041151bf607ee858e5a56c62d4b70f4dac85b8c8cb7fb8a351ab2c10", size = 255094 }, + { url = "https://files.pythonhosted.org/packages/41/5f/cad1c3dbed8b3ee9e16fa832afe365b4e3eeab1fb6edb65ebbf745eabc92/coverage-7.9.1-cp313-cp313t-win32.whl", hash = "sha256:684e2110ed84fd1ca5f40e89aa44adf1729dc85444004111aa01866507adf363", size = 215437 }, + { url = "https://files.pythonhosted.org/packages/99/4d/fad293bf081c0e43331ca745ff63673badc20afea2104b431cdd8c278b4c/coverage-7.9.1-cp313-cp313t-win_amd64.whl", hash = "sha256:437c576979e4db840539674e68c84b3cda82bc824dd138d56bead1435f1cb5d7", size = 216605 }, + { url = "https://files.pythonhosted.org/packages/1f/56/4ee027d5965fc7fc126d7ec1187529cc30cc7d740846e1ecb5e92d31b224/coverage-7.9.1-cp313-cp313t-win_arm64.whl", hash = "sha256:18a0912944d70aaf5f399e350445738a1a20b50fbea788f640751c2ed9208b6c", size = 214392 }, + { url = "https://files.pythonhosted.org/packages/a5/d6/c41dd9b02bf16ec001aaf1cbef665537606899a3db1094e78f5ae17540ca/coverage-7.9.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:6f424507f57878e424d9a95dc4ead3fbdd72fd201e404e861e465f28ea469951", size = 212029 }, + { url = "https://files.pythonhosted.org/packages/f8/c0/40420d81d731f84c3916dcdf0506b3e6c6570817bff2576b83f780914ae6/coverage-7.9.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:535fde4001b2783ac80865d90e7cc7798b6b126f4cd8a8c54acfe76804e54e58", size = 212407 }, + { url = "https://files.pythonhosted.org/packages/9b/87/f0db7d62d0e09f14d6d2f6ae8c7274a2f09edf74895a34b412a0601e375a/coverage-7.9.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:02532fd3290bb8fa6bec876520842428e2a6ed6c27014eca81b031c2d30e3f71", size = 241160 }, + { url = "https://files.pythonhosted.org/packages/a9/b7/3337c064f058a5d7696c4867159651a5b5fb01a5202bcf37362f0c51400e/coverage-7.9.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:56f5eb308b17bca3bbff810f55ee26d51926d9f89ba92707ee41d3c061257e55", size = 239027 }, + { url = "https://files.pythonhosted.org/packages/7e/a9/5898a283f66d1bd413c32c2e0e05408196fd4f37e206e2b06c6e0c626e0e/coverage-7.9.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bfa447506c1a52271f1b0de3f42ea0fa14676052549095e378d5bff1c505ff7b", size = 240145 }, + { url = "https://files.pythonhosted.org/packages/e0/33/d96e3350078a3c423c549cb5b2ba970de24c5257954d3e4066e2b2152d30/coverage-7.9.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:9ca8e220006966b4a7b68e8984a6aee645a0384b0769e829ba60281fe61ec4f7", size = 239871 }, + { url = "https://files.pythonhosted.org/packages/1d/6e/6fb946072455f71a820cac144d49d11747a0f1a21038060a68d2d0200499/coverage-7.9.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:49f1d0788ba5b7ba65933f3a18864117c6506619f5ca80326b478f72acf3f385", size = 238122 }, + { url = "https://files.pythonhosted.org/packages/e4/5c/bc43f25c8586840ce25a796a8111acf6a2b5f0909ba89a10d41ccff3920d/coverage-7.9.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:68cd53aec6f45b8e4724c0950ce86eacb775c6be01ce6e3669fe4f3a21e768ed", size = 239058 }, + { url = "https://files.pythonhosted.org/packages/11/d8/ce2007418dd7fd00ff8c8b898bb150bb4bac2d6a86df05d7b88a07ff595f/coverage-7.9.1-cp39-cp39-win32.whl", hash = "sha256:95335095b6c7b1cc14c3f3f17d5452ce677e8490d101698562b2ffcacc304c8d", size = 214532 }, + { url = "https://files.pythonhosted.org/packages/20/21/334e76fa246e92e6d69cab217f7c8a70ae0cc8f01438bd0544103f29528e/coverage-7.9.1-cp39-cp39-win_amd64.whl", hash = "sha256:e1b5191d1648acc439b24721caab2fd0c86679d8549ed2c84d5a7ec1bedcc244", size = 215439 }, + { url = "https://files.pythonhosted.org/packages/3e/e5/c723545c3fd3204ebde3b4cc4b927dce709d3b6dc577754bb57f63ca4a4a/coverage-7.9.1-pp39.pp310.pp311-none-any.whl", hash = "sha256:db0f04118d1db74db6c9e1cb1898532c7dcc220f1d2718f058601f7c3f499514", size = 204009 }, + { url = "https://files.pythonhosted.org/packages/08/b8/7ddd1e8ba9701dea08ce22029917140e6f66a859427406579fd8d0ca7274/coverage-7.9.1-py3-none-any.whl", hash = "sha256:66b974b145aa189516b6bf2d8423e888b742517d37872f6ee4c5be0073bd9a3c", size = 204000 }, +] + +[package.optional-dependencies] +toml = [ + { name = "tomli", marker = "python_full_version <= '3.11'" }, +] + [[package]] name = "cycler" version = "0.12.1" @@ -442,6 +521,7 @@ img2table = [ [package.dev-dependencies] dev = [ { name = "pytest" }, + { name = "pytest-cov" }, { name = "ruff" }, ] docs = [ @@ -467,6 +547,7 @@ provides-extras = ["img2table"] [package.metadata.requires-dev] dev = [ { name = "pytest", specifier = ">=8.3.5" }, + { name = "pytest-cov", specifier = ">=6.2.1" }, { name = "ruff", specifier = ">=0.11.11" }, ] docs = [ @@ -1766,6 +1847,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/30/3d/64ad57c803f1fa1e963a7946b6e0fea4a70df53c1a7fed304586539c2bac/pytest-8.3.5-py3-none-any.whl", hash = "sha256:c69214aa47deac29fad6c2a4f590b9c4a9fdb16a403176fe154b79c0b4d4d820", size = 343634 }, ] +[[package]] +name = "pytest-cov" +version = "6.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage", extra = ["toml"] }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/99/668cade231f434aaa59bbfbf49469068d2ddd945000621d3d165d2e7dd7b/pytest_cov-6.2.1.tar.gz", hash = "sha256:25cc6cc0a5358204b8108ecedc51a9b57b34cc6b8c967cc2c01a4e00d8a67da2", size = 69432 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/16/4ea354101abb1287856baa4af2732be351c7bee728065aed451b678153fd/pytest_cov-6.2.1-py3-none-any.whl", hash = "sha256:f5bc4c23f42f1cdd23c70b1dab1bbaef4fc505ba950d53e0081d0730dd7e86d5", size = 24644 }, +] + [[package]] name = "python-dateutil" version = "2.9.0.post0" From 240ccbc0334dc86edc0aaf360d0319cfdf973960 Mon Sep 17 00:00:00 2001 From: conjuncts <67614673+conjuncts@users.noreply.github.com> Date: Sun, 22 Jun 2025 17:40:51 -0500 Subject: [PATCH 6/7] Better deprecations --- gmft/core/_dataclasses.py | 75 ------------------------------ gmft/core/legacy/removed_config.py | 68 +++++++++++++++++++++++++++ gmft/impl/ditr/config.py | 29 ++++++------ gmft/impl/tatr/config.py | 57 ++--------------------- pyproject.toml | 1 + 5 files changed, 86 insertions(+), 144 deletions(-) create mode 100644 gmft/core/legacy/removed_config.py diff --git a/gmft/core/_dataclasses.py b/gmft/core/_dataclasses.py index d4b1368..dca779d 100644 --- a/gmft/core/_dataclasses.py +++ b/gmft/core/_dataclasses.py @@ -56,78 +56,3 @@ def non_defaults_only(config: object) -> dict: if default_value != current_value: result[f.name] = current_value return result - - -import warnings - -string_types = (type(b""), type("")) - - -def removed_property(reason): - """ - Custom decorator for marking class properties as removed. - Automatically raises a DeprecationWarning when the property is accessed or set. - - See https://stackoverflow.com/questions/2536307/decorators-in-the-python-standard-lib-deprecated-specifically - """ - if isinstance(reason, string_types): - # The @deprecated is used with a 'reason'. - # - # .. code-block:: python - # - # @deprecated("please, use another function") - # def old_function(x, y): - # pass - - def decorator(func1): - if inspect.isclass(func1): - fmt1 = "Call to deprecated class {name} ({reason})." - else: - fmt1 = "Call to deprecated function {name} ({reason})." - - @functools.wraps(func1) - def new_func1(*args, **kwargs): - warnings.simplefilter("always", DeprecationWarning) - warnings.warn( - fmt1.format(name=func1.__name__, reason=reason), - category=DeprecationWarning, - stacklevel=2, - ) - warnings.simplefilter("default", DeprecationWarning) - return func1(*args, **kwargs) - - return new_func1 - - return decorator - - elif inspect.isclass(reason) or inspect.isfunction(reason): - # The @deprecated is used without any 'reason'. - # - # .. code-block:: python - # - # @deprecated - # def old_function(x, y): - # pass - - func2 = reason - - if inspect.isclass(func2): - fmt2 = "Call to deprecated class {name}." - else: - fmt2 = "Call to deprecated function {name}." - - @functools.wraps(func2) - def new_func2(*args, **kwargs): - warnings.simplefilter("always", DeprecationWarning) - warnings.warn( - fmt2.format(name=func2.__name__), - category=DeprecationWarning, - stacklevel=2, - ) - warnings.simplefilter("default", DeprecationWarning) - return func2(*args, **kwargs) - - return new_func2 - - else: - raise TypeError(repr(type(reason))) diff --git a/gmft/core/legacy/removed_config.py b/gmft/core/legacy/removed_config.py new file mode 100644 index 0000000..0625d1d --- /dev/null +++ b/gmft/core/legacy/removed_config.py @@ -0,0 +1,68 @@ +from typing_extensions import deprecated + + +class LegacyRemovedConfig: + """ + This class contains legacy configuration settings that will soon be removed. + """ + + # ---- deprecated ---- + # aggregate_spanning_cells = False + @property + @deprecated("This config setting is unused and will be removed in v0.6.0") + def aggregate_spanning_cells(self): + raise DeprecationWarning( + "aggregate_spanning_cells has been removed. Will break in v0.6.0." + ) + + @aggregate_spanning_cells.setter + @deprecated("This config setting is unused and will be removed in v0.6.0") + def aggregate_spanning_cells(self, value): + raise DeprecationWarning( + "aggregate_spanning_cells has been removed. Will break in v0.6.0." + ) + + # corner_clip_outlier_threshold = 0.1 + # """"corner clip" is when the text is clipped by a corner, and not an edge""" + @property + @deprecated("This config setting is unused and will be removed in v0.6.0") + def corner_clip_outlier_threshold(self): + raise DeprecationWarning( + "corner_clip_outlier_threshold has been removed. Will break in v0.6.0." + ) + + @corner_clip_outlier_threshold.setter + @deprecated("This config setting is unused and will be removed in v0.6.0") + def corner_clip_outlier_threshold(self, value): + raise DeprecationWarning( + "corner_clip_outlier_threshold has been removed. Will break in v0.6.0." + ) + + # spanning_cell_minimum_width = 0.6 + @property + @deprecated("This config setting is unused and will be removed in v0.6.0") + def spanning_cell_minimum_width(self): + raise DeprecationWarning( + "spanning_cell_minimum_width has been removed. Will break in v0.6.0." + ) + + @spanning_cell_minimum_width.setter + @deprecated("This config setting is unused and will be removed in v0.6.0") + def spanning_cell_minimum_width(self, value): + raise DeprecationWarning( + "spanning_cell_minimum_width has been removed. Will break in v0.6.0." + ) + + @property + @deprecated("This config setting is unused and will be removed in v0.6.0") + def deduplication_iob_threshold(self): + raise DeprecationWarning( + "deduplication_iob_threshold is deprecated. See nms_overlap_threshold instead. Will break in v0.6.0." + ) + + @deduplication_iob_threshold.setter + @deprecated("This config setting is unused and will be removed in v0.6.0") + def deduplication_iob_threshold(self, value): + raise DeprecationWarning( + "deduplication_iob_threshold is deprecated. See nms_overlap_threshold instead. Will break in v0.6.0." + ) diff --git a/gmft/impl/ditr/config.py b/gmft/impl/ditr/config.py index 815e709..76b7460 100644 --- a/gmft/impl/ditr/config.py +++ b/gmft/impl/ditr/config.py @@ -1,9 +1,8 @@ -from gmft.core._dataclasses import removed_property -from gmft.formatters.histogram import HistogramConfig - -from dataclasses import dataclass, field -from typing import Literal, Union +from dataclasses import dataclass +from typing import Literal +from typing_extensions import deprecated +from gmft.formatters.histogram import HistogramConfig from gmft.impl.tatr.config import TATRFormatConfig @@ -44,19 +43,19 @@ class DITRFormatConfig(HistogramConfig, TATRFormatConfig): # hence nms is also not useful anymore. - @removed_property("Large table approach ({name}) is not used for the DITR model.") + @deprecated("Large table approach ({name}) is not used for the DITR model.") def large_table_if_n_rows_removed(self): pass - @removed_property("Large table approach ({name}) is not used for the DITR model.") + @deprecated("Large table approach ({name}) is not used for the DITR model.") def large_table_threshold(self): pass - @removed_property("Large table approach ({name}) is not used for the DITR model.") + @deprecated("Large table approach ({name}) is not used for the DITR model.") def large_table_row_overlap_threshold(self): pass - @removed_property("Large table approach ({name}) is not used for the DITR model.") + @deprecated("Large table approach ({name}) is not used for the DITR model.") def force_large_table_assumption(self): pass @@ -67,23 +66,23 @@ def force_large_table_assumption(self): # hence nms is also not useful anymore. - @removed_property("Overlap ({name}) is not used for the DITR model.") + @deprecated("Overlap ({name}) is not used for the DITR model.") def total_overlap_reject_threshold(self): pass - @removed_property("Overlap ({name}) is not used for the DITR model.") + @deprecated("Overlap ({name}) is not used for the DITR model.") def total_overlap_warn_threshold(self): pass - @removed_property("Overlap (nms) ({name}) is not used for the DITR model.") + @deprecated("Overlap (nms) ({name}) is not used for the DITR model.") def nms_warn_threshold(self): pass - @removed_property("Overlap ({name}) is not used for the DITR model.") + @deprecated("Overlap ({name}) is not used for the DITR model.") def iob_reject_threshold(self): pass - @removed_property("Overlap ({name}) is not used for the DITR model.") + @deprecated("Overlap ({name}) is not used for the DITR model.") def iob_warn_threshold(self): pass @@ -91,6 +90,6 @@ def iob_warn_threshold(self): _nms_overlap_threshold_larger: float = 0.5 - @removed_property("Large table approach ({name}) is not used for the DITR model.") + @deprecated("Large table approach ({name}) is not used for the DITR model.") def _large_table_merge_distance(self): pass diff --git a/gmft/impl/tatr/config.py b/gmft/impl/tatr/config.py index 15bd125..3f806a9 100644 --- a/gmft/impl/tatr/config.py +++ b/gmft/impl/tatr/config.py @@ -5,9 +5,11 @@ from typing import Literal, Union from typing_extensions import deprecated +from gmft.core.legacy.removed_config import LegacyRemovedConfig + @dataclass -class TATRFormatConfig: +class TATRFormatConfig(LegacyRemovedConfig): """ Configuration for :class:`.TATRTableFormatter`. """ @@ -141,56 +143,3 @@ class TATRFormatConfig: _smallest_supported_text_height: float = 0.1 """The smallest supported text height. Text smaller than this height will be ignored. Helps prevent very small text from creating huge arrays under large table assumption.""" - - # ---- deprecated ---- - # aggregate_spanning_cells = False - @property - def aggregate_spanning_cells(self): - raise DeprecationWarning( - "aggregate_spanning_cells has been removed. Will break in v0.6.0." - ) - - @aggregate_spanning_cells.setter - def aggregate_spanning_cells(self, value): - raise DeprecationWarning( - "aggregate_spanning_cells has been removed. Will break in v0.6.0." - ) - - # corner_clip_outlier_threshold = 0.1 - # """"corner clip" is when the text is clipped by a corner, and not an edge""" - @property - def corner_clip_outlier_threshold(self): - raise DeprecationWarning( - "corner_clip_outlier_threshold has been removed. Will break in v0.6.0." - ) - - @corner_clip_outlier_threshold.setter - def corner_clip_outlier_threshold(self, value): - raise DeprecationWarning( - "corner_clip_outlier_threshold has been removed. Will break in v0.6.0." - ) - - # spanning_cell_minimum_width = 0.6 - @property - def spanning_cell_minimum_width(self): - raise DeprecationWarning( - "spanning_cell_minimum_width has been removed. Will break in v0.6.0." - ) - - @spanning_cell_minimum_width.setter - def spanning_cell_minimum_width(self, value): - raise DeprecationWarning( - "spanning_cell_minimum_width has been removed. Will break in v0.6.0." - ) - - @property - def deduplication_iob_threshold(self): - raise DeprecationWarning( - "deduplication_iob_threshold is deprecated. See nms_overlap_threshold instead. Will break in v0.6.0." - ) - - @deduplication_iob_threshold.setter - def deduplication_iob_threshold(self, value): - raise DeprecationWarning( - "deduplication_iob_threshold is deprecated. See nms_overlap_threshold instead. Will break in v0.6.0." - ) diff --git a/pyproject.toml b/pyproject.toml index 835179c..98f5aee 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,6 +23,7 @@ dependencies = [ "pandas", "matplotlib", "tabulate>=0.9.0", + "typing_extensions>=4.9" ] [project.urls] From ed8b7f32c18bff8e719edd71c217cc062078e217 Mon Sep 17 00:00:00 2001 From: conjuncts <67614673+conjuncts@users.noreply.github.com> Date: Sun, 22 Jun 2025 17:58:02 -0500 Subject: [PATCH 7/7] Github CI workflow --- .github/workflows/test.yml | 45 ++++++++++++++++++++++++++++++++++++++ pytest.ini | 13 +++++++++++ 2 files changed, 58 insertions(+) create mode 100644 .github/workflows/test.yml create mode 100644 pytest.ini diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..26ca824 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,45 @@ +name: Tests + +on: + push: + branches: [ main, dev ] + pull_request: + branches: [ main, dev ] + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.9", "3.10", "3.11", "3.12"] + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + + - name: Install uv + uses: astral-sh/setup-uv@v3 + with: + version: "latest" + + - name: Cache uv dependencies + uses: actions/cache@v3 + with: + path: | + .venv + .uv/cache + key: ${{ runner.os }}-uv-${{ hashFiles('**/uv.lock') }} + restore-keys: | + ${{ runner.os }}-uv- + + - name: Install dependencies + run: | + uv sync --group dev + + - name: Run tests + run: | + uv run pytest test/ \ No newline at end of file diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..c74dd3a --- /dev/null +++ b/pytest.ini @@ -0,0 +1,13 @@ +[tool:pytest] +testpaths = test +python_files = test_*.py +python_classes = Test* +python_functions = test_* +addopts = + --strict-markers + --strict-config + --tb=short +markers = + slow: marks tests as slow (deselect with '-m "not slow"') + integration: marks tests as integration tests + unit: marks tests as unit tests \ No newline at end of file