修改为东南天坐标系
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,180 @@
|
||||
import difflib
|
||||
from typing import Any, Dict, List
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from unstructured_inference.models.eval import compare_contents_as_df
|
||||
|
||||
|
||||
class TableAlignment:
|
||||
def __init__(self, cutoff: float = 0.8):
|
||||
self.cutoff = cutoff
|
||||
|
||||
@staticmethod
|
||||
def get_content_in_tables(table_data: List[List[Dict[str, Any]]]) -> List[str]:
|
||||
# Replace below docstring with google-style docstring
|
||||
"""Extracts and concatenates the content of cells from each table in a list of tables.
|
||||
|
||||
Args:
|
||||
table_data: A list of tables, each table being a list of cell data dictionaries.
|
||||
|
||||
Returns:
|
||||
List of strings where each string represents the concatenated content of one table.
|
||||
"""
|
||||
return [" ".join([d["content"] for d in td if "content" in d]) for td in table_data]
|
||||
|
||||
@staticmethod
|
||||
def get_table_level_alignment(
|
||||
predicted_table_data: List[List[Dict[str, Any]]],
|
||||
ground_truth_table_data: List[List[Dict[str, Any]]],
|
||||
) -> List[int]:
|
||||
"""Compares predicted table data with ground truth data to find the best
|
||||
matching table index for each predicted table.
|
||||
|
||||
Args:
|
||||
predicted_table_data: A list of predicted tables.
|
||||
ground_truth_table_data: A list of ground truth tables.
|
||||
|
||||
Returns:
|
||||
A list of indices indicating the best match in the ground truth for
|
||||
each predicted table.
|
||||
|
||||
"""
|
||||
ground_truth_texts = TableAlignment.get_content_in_tables(ground_truth_table_data)
|
||||
matched_indices = []
|
||||
for td in predicted_table_data:
|
||||
reference = TableAlignment.get_content_in_tables([td])[0]
|
||||
matches = difflib.get_close_matches(reference, ground_truth_texts, cutoff=0.1, n=1)
|
||||
matched_indices.append(ground_truth_texts.index(matches[0]) if matches else -1)
|
||||
return matched_indices
|
||||
|
||||
@staticmethod
|
||||
def _zip_to_dataframe(table_data: List[Dict[str, Any]]) -> pd.DataFrame:
|
||||
df = pd.DataFrame(table_data, columns=["row_index", "col_index", "content"])
|
||||
df = df.set_index("row_index")
|
||||
df["col_index"] = df["col_index"].astype(str)
|
||||
return df
|
||||
|
||||
@staticmethod
|
||||
def get_element_level_alignment(
|
||||
predicted_table_data: List[List[Dict[str, Any]]],
|
||||
ground_truth_table_data: List[List[Dict[str, Any]]],
|
||||
matched_indices: List[int],
|
||||
cutoff: float = 0.8,
|
||||
) -> Dict[str, float]:
|
||||
"""Aligns elements of the predicted tables with the ground truth tables at the cell level.
|
||||
|
||||
Args:
|
||||
predicted_table_data: A list of predicted tables.
|
||||
ground_truth_table_data: A list of ground truth tables.
|
||||
matched_indices: Indices of the best matching ground truth table for each predicted table.
|
||||
cutoff: The cutoff value for the close matches.
|
||||
|
||||
Returns:
|
||||
A dictionary with column and row alignment accuracies.
|
||||
|
||||
"""
|
||||
content_diff_cols = []
|
||||
content_diff_rows = []
|
||||
col_index_acc = []
|
||||
row_index_acc = []
|
||||
|
||||
for idx, td in zip(matched_indices, predicted_table_data):
|
||||
if idx == -1:
|
||||
content_diff_cols.append(0)
|
||||
content_diff_rows.append(0)
|
||||
col_index_acc.append(0)
|
||||
row_index_acc.append(0)
|
||||
continue
|
||||
ground_truth_td = ground_truth_table_data[idx]
|
||||
|
||||
# Get row and col content accuracy
|
||||
predict_table_df = TableAlignment._zip_to_dataframe(td)
|
||||
ground_truth_table_df = TableAlignment._zip_to_dataframe(ground_truth_td)
|
||||
|
||||
table_content_diff = compare_contents_as_df(
|
||||
ground_truth_table_df.fillna(""),
|
||||
predict_table_df.fillna(""),
|
||||
)
|
||||
content_diff_cols.append(table_content_diff["by_col_token_ratio"])
|
||||
content_diff_rows.append(table_content_diff["by_row_token_ratio"])
|
||||
|
||||
aligned_element_col_count = 0
|
||||
aligned_element_row_count = 0
|
||||
total_element_count = 0
|
||||
# Get row and col index accuracy
|
||||
ground_truth_td_contents_list = [gtd["content"].lower() for gtd in ground_truth_td]
|
||||
used_indices = set()
|
||||
indices_tuple_pairs = []
|
||||
for td_ele in td:
|
||||
content = td_ele["content"].lower()
|
||||
row_index = td_ele["row_index"]
|
||||
col_idx = td_ele["col_index"]
|
||||
|
||||
matches = difflib.get_close_matches(
|
||||
content,
|
||||
ground_truth_td_contents_list,
|
||||
cutoff=cutoff,
|
||||
n=1,
|
||||
)
|
||||
# BUG FIX: the previous matched_idx will only output the first matched index if
|
||||
# the match has duplicates in the
|
||||
# ground_truth_td_contents_list, the current fix will output its correspondence idx
|
||||
# once matching is exhausted, it will go back search again the same fashion
|
||||
matching_indices = []
|
||||
if matches != []:
|
||||
b_indices = [
|
||||
i
|
||||
for i, b_string in enumerate(ground_truth_td_contents_list)
|
||||
if b_string == matches[0] and i not in used_indices
|
||||
]
|
||||
if not b_indices:
|
||||
# If all indices are used, reset used_indices and use the first index
|
||||
used_indices.clear()
|
||||
b_indices = [
|
||||
i
|
||||
for i, b_string in enumerate(ground_truth_td_contents_list)
|
||||
if b_string == matches[0] and i not in used_indices
|
||||
]
|
||||
matching_index = b_indices[0]
|
||||
matching_indices.append(matching_index)
|
||||
used_indices.add(matching_index)
|
||||
else:
|
||||
matching_indices = [-1]
|
||||
matched_idx = matching_indices[0]
|
||||
if matched_idx >= 0:
|
||||
gt_row_index = ground_truth_td[matched_idx]["row_index"]
|
||||
gt_col_index = ground_truth_td[matched_idx]["col_index"]
|
||||
indices_tuple_pairs.append(((row_index, col_idx), (gt_row_index, gt_col_index)))
|
||||
|
||||
for indices_tuple_pair in indices_tuple_pairs:
|
||||
if indices_tuple_pair[0][0] == indices_tuple_pair[1][0]:
|
||||
aligned_element_row_count += 1
|
||||
if indices_tuple_pair[0][1] == indices_tuple_pair[1][1]:
|
||||
aligned_element_col_count += 1
|
||||
total_element_count += 1
|
||||
|
||||
table_col_index_acc = 0
|
||||
table_row_index_acc = 0
|
||||
if total_element_count > 0:
|
||||
table_col_index_acc = round(aligned_element_col_count / total_element_count, 2)
|
||||
table_row_index_acc = round(aligned_element_row_count / total_element_count, 2)
|
||||
|
||||
col_index_acc.append(table_col_index_acc)
|
||||
row_index_acc.append(table_row_index_acc)
|
||||
|
||||
not_found_gt_table_indexes = [
|
||||
id for id in range(len(ground_truth_table_data)) if id not in matched_indices
|
||||
]
|
||||
for _ in not_found_gt_table_indexes:
|
||||
content_diff_cols.append(0)
|
||||
content_diff_rows.append(0)
|
||||
col_index_acc.append(0)
|
||||
row_index_acc.append(0)
|
||||
|
||||
return {
|
||||
"col_index_acc": round(np.mean(col_index_acc), 2),
|
||||
"row_index_acc": round(np.mean(row_index_acc), 2),
|
||||
"col_content_acc": round(np.mean(content_diff_cols) / 100.0, 2),
|
||||
"row_content_acc": round(np.mean(content_diff_rows) / 100.0, 2),
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
"""
|
||||
The purpose of this script is to create a comprehensive metric for table evaluation
|
||||
1. Verify table identification.
|
||||
a. Concatenate all text in the table and ground truth.
|
||||
b. Calculate the difference to find the closest matches.
|
||||
c. If contents are too different, mark as a failure.
|
||||
|
||||
2. For each identified table:
|
||||
a. Align elements at the level of individual elements.
|
||||
b. Match elements by text.
|
||||
c. Determine indexes for both predicted and actual data.
|
||||
d. Compare index tuples at column and row levels to assess content shifts.
|
||||
e. Compare the token orders by flattened along column and row levels
|
||||
f. Note: Imperfect HTML is acceptable unless it impedes parsing,
|
||||
in which case the table is considered failed.
|
||||
|
||||
Example
|
||||
python table_eval.py \
|
||||
--prediction_file "model_output.pdf.json" \
|
||||
--ground_truth_file "ground_truth.pdf.json"
|
||||
"""
|
||||
|
||||
import difflib
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import click
|
||||
import numpy as np
|
||||
|
||||
from unstructured.metrics.table.table_alignment import TableAlignment
|
||||
from unstructured.metrics.table.table_extraction import (
|
||||
extract_and_convert_tables_from_ground_truth,
|
||||
extract_and_convert_tables_from_prediction,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class TableEvaluation:
|
||||
"""Class representing a gathered table metrics."""
|
||||
|
||||
total_tables: int
|
||||
total_predicted_tables: int
|
||||
table_level_acc: float
|
||||
table_detection_recall: float
|
||||
table_detection_precision: float
|
||||
table_detection_f1: float
|
||||
element_col_level_index_acc: float
|
||||
element_row_level_index_acc: float
|
||||
element_col_level_content_acc: float
|
||||
element_row_level_content_acc: float
|
||||
|
||||
@property
|
||||
def composite_structure_acc(self) -> float:
|
||||
return (
|
||||
self.element_col_level_index_acc
|
||||
+ self.element_row_level_index_acc
|
||||
+ (self.element_col_level_content_acc + self.element_row_level_content_acc) / 2
|
||||
) / 3
|
||||
|
||||
|
||||
def table_level_acc(predicted_table_data, ground_truth_table_data, matched_indices):
|
||||
"""computes for each predicted table its accurary compared to ground truth.
|
||||
|
||||
The accuracy is defined as the SequenceMatcher.ratio() between those two strings. If a
|
||||
prediction does not have a matched ground truth its accuracy is 0
|
||||
"""
|
||||
score = np.zeros((len(matched_indices),))
|
||||
ground_truth_text = TableAlignment.get_content_in_tables(ground_truth_table_data)
|
||||
for idx, predicted in enumerate(predicted_table_data):
|
||||
matched_idx = matched_indices[idx]
|
||||
if matched_idx == -1:
|
||||
# false positive; default score 0
|
||||
continue
|
||||
score[idx] = difflib.SequenceMatcher(
|
||||
None,
|
||||
TableAlignment.get_content_in_tables([predicted])[0],
|
||||
ground_truth_text[matched_idx],
|
||||
).ratio()
|
||||
return score
|
||||
|
||||
|
||||
def _count_predicted_tables(matched_indices: List[int]) -> int:
|
||||
"""Counts the number of predicted tables that have a corresponding match in the ground truth.
|
||||
|
||||
Args:
|
||||
matched_indices: List of indices indicating matches between predicted
|
||||
and ground truth tables.
|
||||
|
||||
Returns:
|
||||
The count of matched predicted tables.
|
||||
|
||||
"""
|
||||
return sum(1 for idx in matched_indices if idx >= 0)
|
||||
|
||||
|
||||
def calculate_table_detection_metrics(
|
||||
matched_indices: list[int], ground_truth_tables_number: int
|
||||
) -> tuple[float, float, float]:
|
||||
"""
|
||||
Calculate the table detection metrics: recall, precision, and f1 score.
|
||||
Args:
|
||||
matched_indices:
|
||||
List of indices indicating matches between predicted and ground truth tables
|
||||
For example: matched_indices[i] = j means that the
|
||||
i-th predicted table is matched with the j-th ground truth table.
|
||||
ground_truth_tables_number: the number of ground truth tables.
|
||||
|
||||
Returns:
|
||||
Tuple of recall, precision, and f1 scores
|
||||
"""
|
||||
predicted_tables_number = len(matched_indices)
|
||||
|
||||
matched_set = set(matched_indices)
|
||||
if -1 in matched_set:
|
||||
matched_set.remove(-1)
|
||||
|
||||
true_positive = len(matched_set)
|
||||
false_positive = predicted_tables_number - true_positive
|
||||
positive = ground_truth_tables_number
|
||||
|
||||
recall = true_positive / positive if positive > 0 else 0
|
||||
precision = (
|
||||
true_positive / (true_positive + false_positive)
|
||||
if true_positive + false_positive > 0
|
||||
else 0
|
||||
)
|
||||
f1 = 2 * precision * recall / (precision + recall) if precision + recall > 0 else 0
|
||||
|
||||
return recall, precision, f1
|
||||
|
||||
|
||||
class TableEvalProcessor:
|
||||
def __init__(
|
||||
self,
|
||||
prediction: List[Dict[str, Any]],
|
||||
ground_truth: List[Dict[str, Any]],
|
||||
cutoff: float = 0.8,
|
||||
source_type: str = "html",
|
||||
):
|
||||
"""
|
||||
Initializes the TableEvalProcessor prediction and ground truth.
|
||||
|
||||
Args:
|
||||
ground_truth: Ground truth table data. The tables text should be in the deckerd format.
|
||||
prediction: Predicted table data.
|
||||
cutoff: The cutoff value for the element level alignment. Default is 0.8.
|
||||
|
||||
Examples:
|
||||
ground_truth: [
|
||||
{
|
||||
"type": "Table",
|
||||
"text": [
|
||||
{
|
||||
"id": "f4c35dae-105b-46f5-a77a-7fbc199d6aca",
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"w": 1,
|
||||
"h": 1,
|
||||
"content": "Cell text"
|
||||
},
|
||||
...
|
||||
}
|
||||
]
|
||||
prediction: [
|
||||
{
|
||||
"element_id": <id_string>,
|
||||
...
|
||||
"metadata": {
|
||||
...
|
||||
"text_as_html": "<table><thead><tr><th rowspan=\"2\">June....
|
||||
</tr></td></table>",
|
||||
"table_as_cells":
|
||||
[
|
||||
{
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"w": 1,
|
||||
"h": 2,
|
||||
"content": "June"
|
||||
},
|
||||
...
|
||||
]
|
||||
}
|
||||
},
|
||||
]
|
||||
|
||||
"""
|
||||
self.prediction = prediction
|
||||
self.ground_truth = ground_truth
|
||||
self.cutoff = cutoff
|
||||
self.source_type = source_type
|
||||
|
||||
@classmethod
|
||||
def from_json_files(
|
||||
cls,
|
||||
prediction_file: Path,
|
||||
ground_truth_file: Path,
|
||||
cutoff: Optional[float] = None,
|
||||
source_type: str = "html",
|
||||
) -> "TableEvalProcessor":
|
||||
"""Factory classmethod to initialize the object with path to json files instead of dicts
|
||||
|
||||
Args:
|
||||
prediction_file: Path to the json file containing the predicted table data.
|
||||
ground_truth_file: Path to the json file containing the ground truth table data.
|
||||
source_type: 'cells' or 'html'. 'cells' refers to reading 'table_as_cells' field while
|
||||
'html' is extracted from 'text_as_html'
|
||||
cutoff: The cutoff value for the element level alignment.
|
||||
If not set, class default value is used (=0.8).
|
||||
|
||||
Returns:
|
||||
TableEvalProcessor: An instance of the class initialized with the provided data.
|
||||
"""
|
||||
with open(prediction_file) as f:
|
||||
prediction = json.load(f)
|
||||
with open(ground_truth_file) as f:
|
||||
ground_truth = json.load(f)
|
||||
if cutoff is not None:
|
||||
return cls(
|
||||
prediction=prediction,
|
||||
ground_truth=ground_truth,
|
||||
cutoff=cutoff,
|
||||
source_type=source_type,
|
||||
)
|
||||
else:
|
||||
return cls(prediction=prediction, ground_truth=ground_truth, source_type=source_type)
|
||||
|
||||
def process_file(self) -> TableEvaluation:
|
||||
"""Processes the files and computes table-level and element-level accuracy.
|
||||
|
||||
Returns:
|
||||
TableEvaluation: A dataclass object containing the computed metrics.
|
||||
"""
|
||||
ground_truth_table_data = extract_and_convert_tables_from_ground_truth(
|
||||
self.ground_truth,
|
||||
)
|
||||
|
||||
predicted_table_data = extract_and_convert_tables_from_prediction(
|
||||
file_elements=self.prediction, source_type=self.source_type
|
||||
)
|
||||
is_table_in_gt = bool(ground_truth_table_data)
|
||||
is_table_predicted = bool(predicted_table_data)
|
||||
if not is_table_in_gt:
|
||||
# There is no table data in ground truth, you either got perfect score or 0
|
||||
score = 0 if is_table_predicted else np.nan
|
||||
table_acc = 1 if not is_table_predicted else 0
|
||||
return TableEvaluation(
|
||||
total_tables=0,
|
||||
total_predicted_tables=len(predicted_table_data),
|
||||
table_level_acc=table_acc,
|
||||
table_detection_recall=score,
|
||||
table_detection_precision=score,
|
||||
table_detection_f1=score,
|
||||
element_col_level_index_acc=score,
|
||||
element_row_level_index_acc=score,
|
||||
element_col_level_content_acc=score,
|
||||
element_row_level_content_acc=score,
|
||||
)
|
||||
if is_table_in_gt and not is_table_predicted:
|
||||
return TableEvaluation(
|
||||
total_tables=len(ground_truth_table_data),
|
||||
total_predicted_tables=0,
|
||||
table_level_acc=0,
|
||||
table_detection_recall=0,
|
||||
table_detection_precision=0,
|
||||
table_detection_f1=0,
|
||||
element_col_level_index_acc=0,
|
||||
element_row_level_index_acc=0,
|
||||
element_col_level_content_acc=0,
|
||||
element_row_level_content_acc=0,
|
||||
)
|
||||
else:
|
||||
# We have both ground truth tables and predicted tables
|
||||
matched_indices = TableAlignment.get_table_level_alignment(
|
||||
predicted_table_data,
|
||||
ground_truth_table_data,
|
||||
)
|
||||
predicted_table_acc = np.mean(
|
||||
table_level_acc(predicted_table_data, ground_truth_table_data, matched_indices)
|
||||
)
|
||||
|
||||
metrics = TableAlignment.get_element_level_alignment(
|
||||
predicted_table_data,
|
||||
ground_truth_table_data,
|
||||
matched_indices,
|
||||
cutoff=self.cutoff,
|
||||
)
|
||||
|
||||
(
|
||||
table_detection_recall,
|
||||
table_detection_precision,
|
||||
table_detection_f1,
|
||||
) = calculate_table_detection_metrics(
|
||||
matched_indices=matched_indices,
|
||||
ground_truth_tables_number=len(ground_truth_table_data),
|
||||
)
|
||||
|
||||
evaluation = TableEvaluation(
|
||||
total_tables=len(ground_truth_table_data),
|
||||
total_predicted_tables=len(predicted_table_data),
|
||||
table_level_acc=predicted_table_acc,
|
||||
table_detection_recall=table_detection_recall,
|
||||
table_detection_precision=table_detection_precision,
|
||||
table_detection_f1=table_detection_f1,
|
||||
element_col_level_index_acc=metrics.get("col_index_acc", 0),
|
||||
element_row_level_index_acc=metrics.get("row_index_acc", 0),
|
||||
element_col_level_content_acc=metrics.get("col_content_acc", 0),
|
||||
element_row_level_content_acc=metrics.get("row_content_acc", 0),
|
||||
)
|
||||
return evaluation
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option(
|
||||
"--prediction_file", help="Path to the model prediction JSON file", type=click.Path(exists=True)
|
||||
)
|
||||
@click.option(
|
||||
"--ground_truth_file", help="Path to the ground truth JSON file", type=click.Path(exists=True)
|
||||
)
|
||||
@click.option(
|
||||
"--cutoff",
|
||||
type=float,
|
||||
show_default=True,
|
||||
default=0.8,
|
||||
help="The cutoff value for the element level alignment. \
|
||||
If not set, a default value is used",
|
||||
)
|
||||
def run(prediction_file: str, ground_truth_file: str, cutoff: Optional[float]):
|
||||
"""Runs the table evaluation process and prints the computed metrics."""
|
||||
processor = TableEvalProcessor.from_json_files(
|
||||
Path(prediction_file),
|
||||
Path(ground_truth_file),
|
||||
cutoff=cutoff,
|
||||
)
|
||||
report = processor.process_file()
|
||||
print(report)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run()
|
||||
@@ -0,0 +1,288 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from bs4 import BeautifulSoup
|
||||
from unstructured_inference.models.tables import cells_to_html
|
||||
|
||||
EMPTY_CELL = {
|
||||
"row_index": "",
|
||||
"col_index": "",
|
||||
"content": "",
|
||||
}
|
||||
|
||||
|
||||
def _move_cells_for_spanned_cells(cells: List[Dict[str, Any]]):
|
||||
"""Move cells to the right if spanned cells have an influence on the rendering.
|
||||
|
||||
Args:
|
||||
cells: List of cells in the table in Deckerd format.
|
||||
|
||||
Returns:
|
||||
List of cells in the table in Deckerd format with cells moved to the right if spanned.
|
||||
"""
|
||||
sorted_cells = sorted(cells, key=lambda x: (x["y"], x["x"]))
|
||||
cells_occupied_by_spanned = set()
|
||||
for cell in sorted_cells:
|
||||
if cell["w"] > 1 or cell["h"] > 1:
|
||||
for i in range(cell["y"], cell["y"] + cell["h"]):
|
||||
for j in range(cell["x"], cell["x"] + cell["w"]):
|
||||
if (i, j) != (cell["y"], cell["x"]):
|
||||
cells_occupied_by_spanned.add((i, j))
|
||||
while (cell["y"], cell["x"]) in cells_occupied_by_spanned:
|
||||
cell_y, cell_x = cell["y"], cell["x"]
|
||||
cells_to_the_right = [c for c in sorted_cells if c["y"] == cell_y and c["x"] >= cell_x]
|
||||
for cell_to_move in cells_to_the_right:
|
||||
cell_to_move["x"] += 1
|
||||
cells_occupied_by_spanned.remove((cell_y, cell_x))
|
||||
return sorted_cells
|
||||
|
||||
|
||||
def html_table_to_deckerd(content: str) -> List[Dict[str, Any]]:
|
||||
"""Convert html format to Deckerd table structure.
|
||||
|
||||
Args:
|
||||
content: The html content with a table to extract.
|
||||
|
||||
Returns:
|
||||
A list of dictionaries where each dictionary represents a cell in the table.
|
||||
"""
|
||||
|
||||
soup = BeautifulSoup(content, "html.parser")
|
||||
table = soup.find("table")
|
||||
rows = table.find_all(["tr"])
|
||||
table_data = []
|
||||
|
||||
for i, row in enumerate(rows):
|
||||
cells = row.find_all(["th", "td"])
|
||||
for j, cell_data in enumerate(cells):
|
||||
cell = {
|
||||
"y": i,
|
||||
"x": j,
|
||||
"w": int(cell_data.attrs.get("colspan", 1)),
|
||||
"h": int(cell_data.attrs.get("rowspan", 1)),
|
||||
"content": cell_data.text,
|
||||
}
|
||||
table_data.append(cell)
|
||||
return _move_cells_for_spanned_cells(table_data)
|
||||
|
||||
|
||||
def deckerd_table_to_html(cells: List[Dict[str, Any]]) -> str:
|
||||
"""Convert Deckerd table structure to html format.
|
||||
|
||||
Args:
|
||||
cells: List of dictionaries where each dictionary represents a cell in the table.
|
||||
|
||||
Returns:
|
||||
A string with the html content of the table.
|
||||
"""
|
||||
transformer_cells = []
|
||||
# determine which cells are in header. Consider row 0 as header
|
||||
# but spans may make it larger
|
||||
first_row_cells = [cell for cell in cells if cell["y"] == 0]
|
||||
header_length = max(cell["w"] for cell in first_row_cells)
|
||||
header_rows = set(range(header_length))
|
||||
for cell in cells:
|
||||
cell_data = {
|
||||
"row_nums": list(range(cell["y"], cell["y"] + cell["h"])),
|
||||
"column_nums": list(range(cell["x"], cell["x"] + cell["w"])),
|
||||
"w": cell["w"],
|
||||
"h": cell["h"],
|
||||
"cell text": cell["content"],
|
||||
"column header": cell["y"] in header_rows,
|
||||
}
|
||||
transformer_cells.append(cell_data)
|
||||
# reuse the existing function to convert to HTML
|
||||
table = cells_to_html(transformer_cells)
|
||||
return table
|
||||
|
||||
|
||||
def _convert_table_from_html(content: str) -> List[Dict[str, Any]]:
|
||||
"""Convert html format to table structure. As a middle step it converts
|
||||
html to the Deckerd format as it's more convenient to work with.
|
||||
|
||||
Args:
|
||||
content: The html content with a table to extract.
|
||||
|
||||
Returns:
|
||||
A list of dictionaries where each dictionary represents a cell in the table.
|
||||
"""
|
||||
deckerd_cells = html_table_to_deckerd(content)
|
||||
return _convert_table_from_deckerd(deckerd_cells)
|
||||
|
||||
|
||||
def _convert_table_from_deckerd(content: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
"""Convert deckerd format to table structure.
|
||||
|
||||
Args:
|
||||
content: The deckerd formatted content with a table to extract.
|
||||
|
||||
Returns:
|
||||
A list of dictionaries where each dictionary represents a cell in the table.
|
||||
"""
|
||||
table_data = []
|
||||
for table in content:
|
||||
try:
|
||||
cell_data = {
|
||||
"row_index": table["y"],
|
||||
"col_index": table["x"],
|
||||
"content": table["content"],
|
||||
}
|
||||
except KeyError:
|
||||
cell_data = EMPTY_CELL
|
||||
except TypeError:
|
||||
cell_data = EMPTY_CELL
|
||||
table_data.append(cell_data)
|
||||
return table_data
|
||||
|
||||
|
||||
def _sort_table_cells(table_data: List[List[Dict[str, Any]]]) -> List[List[Dict[str, Any]]]:
|
||||
return sorted(table_data, key=lambda cell: (cell["row_index"], cell["col_index"]))
|
||||
|
||||
|
||||
def extract_and_convert_tables_from_ground_truth(
|
||||
file_elements: List[Dict[str, Any]],
|
||||
) -> List[List[Dict[str, Any]]]:
|
||||
"""Extracts and converts tables data to a structured format based on the specified table type.
|
||||
|
||||
Args:
|
||||
file_elements: List of elements from the ground truth file.
|
||||
|
||||
Returns:
|
||||
A list of tables with each table represented as a list of cell data dictionaries.
|
||||
|
||||
"""
|
||||
ground_truth_table_data = []
|
||||
for element in file_elements:
|
||||
if "type" in element and element["type"] == "Table" and "text" in element:
|
||||
try:
|
||||
converted_data = _convert_table_from_deckerd(
|
||||
element["text"],
|
||||
)
|
||||
ground_truth_table_data.append(_sort_table_cells(converted_data))
|
||||
except Exception as e:
|
||||
print(f"Error converting ground truth data: {e}")
|
||||
ground_truth_table_data.append({})
|
||||
|
||||
return ground_truth_table_data
|
||||
|
||||
|
||||
def extract_and_convert_tables_from_prediction(
|
||||
file_elements: List[Dict[str, Any]], source_type: str = "html"
|
||||
) -> List[List[Dict[str, Any]]]:
|
||||
"""Extracts and converts table data to a structured format
|
||||
|
||||
Args:
|
||||
file_elements: List of elements from the file.
|
||||
source_type: 'cells' or 'html'. 'cells' refers to reading 'table_as_cells' field while
|
||||
'html' is extracted from 'text_as_html'
|
||||
|
||||
Returns:
|
||||
A list of tables with each table represented as a list of cell data dictionaries.
|
||||
|
||||
"""
|
||||
source_type_to_extraction_strategies = {
|
||||
"html": extract_cells_from_text_as_html,
|
||||
"cells": extract_cells_from_table_as_cells,
|
||||
}
|
||||
if source_type not in source_type_to_extraction_strategies:
|
||||
raise ValueError(
|
||||
f'source_type {source_type} is not valid. Allowed source_types are "html" and "cells"'
|
||||
)
|
||||
|
||||
extract_cells_fn = source_type_to_extraction_strategies[source_type]
|
||||
fallback_extract_cells_fn = (
|
||||
extract_cells_from_table_as_cells
|
||||
if source_type == "cells"
|
||||
else extract_cells_from_text_as_html
|
||||
)
|
||||
|
||||
predicted_table_data = []
|
||||
for element in file_elements:
|
||||
if element.get("type") == "Table":
|
||||
extracted_cells = extract_cells_fn(element)
|
||||
if not extracted_cells:
|
||||
extracted_cells = fallback_extract_cells_fn(element)
|
||||
if extracted_cells:
|
||||
sorted_cells = _sort_table_cells(extracted_cells)
|
||||
predicted_table_data.append(sorted_cells)
|
||||
|
||||
return predicted_table_data
|
||||
|
||||
|
||||
def extract_cells_from_text_as_html(element: Dict[str, Any]) -> List[Dict[str, Any]] | None:
|
||||
"""Extracts and parse cells from "text_as_html" field in Element structure
|
||||
|
||||
Args:
|
||||
element: Example element:
|
||||
{
|
||||
"type": "Table",
|
||||
"metadata": {
|
||||
"text_as_html": "<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Month A.</th>
|
||||
</tr>
|
||||
</thead>
|
||||
</tbody>
|
||||
<tr>
|
||||
<td>22</td><
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>"
|
||||
}
|
||||
}
|
||||
|
||||
Returns:
|
||||
List of extracted cells in a format:
|
||||
[
|
||||
{
|
||||
"row_index": 0,
|
||||
"col_index": 0,
|
||||
"content": "Month A.",
|
||||
},
|
||||
...,
|
||||
]
|
||||
"""
|
||||
val = element["metadata"].get("text_as_html")
|
||||
if not val or "<table>" not in val:
|
||||
return None
|
||||
|
||||
predicted_cells = None
|
||||
try:
|
||||
predicted_cells = _convert_table_from_html(val)
|
||||
except Exception as e:
|
||||
print(f"Error converting Unstructured table data: {e}")
|
||||
|
||||
return predicted_cells
|
||||
|
||||
|
||||
def extract_cells_from_table_as_cells(element: Dict[str, Any]) -> List[Dict[str, Any]]:
|
||||
"""Extracts and parse cells from "table_as_cells" field in Element structure
|
||||
|
||||
Args:
|
||||
element: Example element:
|
||||
{
|
||||
"type": "Table",
|
||||
"metadata": {
|
||||
"table_as_cells": [{"x": 0, "y": 0, "w": 1, "h": 1, "content": "Month A."},
|
||||
{"x": 0, "y": 1, "w": 1, "h": 1, "content": "22"}]
|
||||
}
|
||||
}
|
||||
|
||||
Returns:
|
||||
List of extracted cells in a format:
|
||||
[
|
||||
{
|
||||
"row_index": 0,
|
||||
"col_index": 0,
|
||||
"content": "Month A.",
|
||||
},
|
||||
...,
|
||||
]
|
||||
"""
|
||||
predicted_cells = element["metadata"].get("table_as_cells")
|
||||
converted_cells = None
|
||||
if predicted_cells:
|
||||
converted_cells = _convert_table_from_deckerd(predicted_cells)
|
||||
return converted_cells
|
||||
@@ -0,0 +1,49 @@
|
||||
from dataclasses import dataclass
|
||||
from typing import Union
|
||||
|
||||
|
||||
@dataclass
|
||||
class SimpleTableCell:
|
||||
x: int
|
||||
y: int
|
||||
w: int
|
||||
h: int
|
||||
content: str = ""
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
"x": self.x,
|
||||
"y": self.y,
|
||||
"w": self.w,
|
||||
"h": self.h,
|
||||
"content": self.content,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_table_transformer_cell(cls, tatr_table_cell: dict[str, Union[list[int], str]]):
|
||||
"""
|
||||
Args:
|
||||
tatr_table_cell (dict):
|
||||
Cell in a format returned by Table Transformer model, for example:
|
||||
{
|
||||
"row_nums": [1,2,3],
|
||||
"column_nums": [2],
|
||||
"cell text": "Text inside cell"
|
||||
}
|
||||
"""
|
||||
|
||||
row_nums = tatr_table_cell.get("row_nums", [])
|
||||
column_nums = tatr_table_cell.get("column_nums", [])
|
||||
|
||||
if not row_nums:
|
||||
raise ValueError(f'Cell {tatr_table_cell} has missing values under "row_nums" key')
|
||||
if not column_nums:
|
||||
raise ValueError(f'Cell {tatr_table_cell} has missing values under "column_nums" key')
|
||||
|
||||
return cls(
|
||||
x=min(column_nums),
|
||||
y=min(row_nums),
|
||||
w=len(column_nums),
|
||||
h=len(row_nums),
|
||||
content=tatr_table_cell.get("cell text", ""),
|
||||
)
|
||||
Reference in New Issue
Block a user