修改为东南天坐标系

This commit is contained in:
2026-01-20 09:49:52 +08:00
parent 9538757047
commit 333fad40ac
7201 changed files with 1030888 additions and 85410 deletions

View File

@@ -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),
}

View File

@@ -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()

View File

@@ -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

View File

@@ -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", ""),
)