mirror of
https://github.com/docling-project/docling-eval.git
synced 2026-05-17 13:10:47 +00:00
* fix: ocr visualization Signed-off-by: samiullahchattha <Sami.Ullah1@ibm.com> * fix type error Signed-off-by: samiullahchattha <Sami.Ullah1@ibm.com> * fix: improve OCR visualizer * fix: build errors * add word and character accuracy metrics Signed-off-by: samiullahchattha <Sami.Ullah1@ibm.com> * strip leading or trailing whitespace in edit distance Signed-off-by: samiullahchattha <Sami.Ullah1@ibm.com> * fi visualizations Signed-off-by: samiuc <sami.ullah.chat@gmail.com> --------- Signed-off-by: samiullahchattha <Sami.Ullah1@ibm.com> Signed-off-by: samiuc <sami.ullah.chat@gmail.com> Co-authored-by: samiullahchattha <Sami.Ullah1@ibm.com>
339 lines
12 KiB
Python
339 lines
12 KiB
Python
import json
|
|
import logging
|
|
import traceback
|
|
from typing import Any, Dict, List, Optional, Tuple
|
|
|
|
import edit_distance
|
|
from docling_core.types.doc import BoundingBox, CoordOrigin
|
|
from docling_core.types.doc.page import (
|
|
BoundingRectangle,
|
|
SegmentedPage,
|
|
TextCell,
|
|
TextDirection,
|
|
)
|
|
|
|
from docling_eval.evaluators.ocr.evaluation_models import Word, _CalculationConstants
|
|
from docling_eval.evaluators.ocr.geometry_utils import create_polygon_from_bbox
|
|
|
|
_log = logging.getLogger(__name__)
|
|
|
|
|
|
def extract_word_from_text_cell(
|
|
text_cell: TextCell, page_height: float, is_ground_truth: bool = False
|
|
) -> Word:
|
|
rect_to_process = text_cell.rect
|
|
if rect_to_process.coord_origin != CoordOrigin.TOPLEFT:
|
|
rect_to_process = rect_to_process.to_top_left_origin(page_height=page_height)
|
|
|
|
polygon_points: List[List[float]] = [
|
|
[float(rect_to_process.r_x0), float(rect_to_process.r_y0)],
|
|
[float(rect_to_process.r_x1), float(rect_to_process.r_y1)],
|
|
[float(rect_to_process.r_x2), float(rect_to_process.r_y2)],
|
|
[float(rect_to_process.r_x3), float(rect_to_process.r_y3)],
|
|
]
|
|
|
|
bbox = rect_to_process.to_bounding_box()
|
|
|
|
width_val: float = bbox.width
|
|
height_val: float = bbox.height
|
|
|
|
is_vertical_flag: bool = False
|
|
if (
|
|
width_val > _CalculationConstants.EPS
|
|
and height_val > (2 * width_val)
|
|
and len(text_cell.text) > 1
|
|
):
|
|
is_vertical_flag = True
|
|
|
|
# For GT: set orig to the GT text; for predictions: prefer provided orig, fallback to text
|
|
orig_val = text_cell.text if is_ground_truth else (text_cell.orig or text_cell.text)
|
|
|
|
return Word(
|
|
rect=rect_to_process,
|
|
text=text_cell.text,
|
|
orig=orig_val,
|
|
text_direction=text_cell.text_direction,
|
|
confidence=text_cell.confidence,
|
|
from_ocr=text_cell.from_ocr,
|
|
vertical=is_vertical_flag,
|
|
polygon=polygon_points,
|
|
)
|
|
|
|
|
|
def convert_word_to_text_cell(word_obj: Word) -> TextCell:
|
|
source_bbox = word_obj.bbox
|
|
|
|
bbox_for_conversion = BoundingBox(
|
|
l=source_bbox.l,
|
|
t=source_bbox.t,
|
|
r=source_bbox.r,
|
|
b=source_bbox.b,
|
|
coord_origin=CoordOrigin.TOPLEFT,
|
|
)
|
|
if source_bbox.coord_origin != CoordOrigin.TOPLEFT:
|
|
pass
|
|
|
|
text_cell_rect = BoundingRectangle.from_bounding_box(bbox_for_conversion)
|
|
|
|
return TextCell(
|
|
rect=text_cell_rect,
|
|
text=word_obj.text,
|
|
orig=word_obj.orig,
|
|
confidence=word_obj.confidence,
|
|
text_direction=word_obj.text_direction,
|
|
from_ocr=word_obj.from_ocr,
|
|
)
|
|
|
|
|
|
def merge_words_into_one(
|
|
words: List[Word], add_space_between_words: bool = True
|
|
) -> Word:
|
|
if not words:
|
|
default_bbox = BoundingBox(l=0, t=0, r=0, b=0, coord_origin=CoordOrigin.TOPLEFT)
|
|
default_rect = BoundingRectangle.from_bounding_box(default_bbox)
|
|
return Word(
|
|
text="",
|
|
rect=default_rect,
|
|
orig="",
|
|
confidence=1.0,
|
|
from_ocr=False,
|
|
text_direction=TextDirection.LEFT_TO_RIGHT,
|
|
vertical=False,
|
|
polygon=create_polygon_from_bbox(default_bbox),
|
|
word_weight=1,
|
|
)
|
|
|
|
separator: str = " " if add_space_between_words else ""
|
|
merged_text_parts: List[str] = []
|
|
merged_orig_parts: List[str] = []
|
|
|
|
min_left: float = float("inf")
|
|
min_top: float = float("inf")
|
|
max_right: float = -float("inf")
|
|
max_bottom: float = -float("inf")
|
|
|
|
sorted_words: List[Word] = sorted(words, key=lambda k: k.bbox.l)
|
|
|
|
first_word_for_metadata = sorted_words[0]
|
|
|
|
for word_item in sorted_words:
|
|
merged_text_parts.append(word_item.text)
|
|
merged_orig_parts.append(word_item.orig)
|
|
current_bbox = word_item.bbox
|
|
min_left = min(min_left, current_bbox.l)
|
|
min_top = min(min_top, current_bbox.t)
|
|
max_right = max(max_right, current_bbox.r)
|
|
max_bottom = max(max_bottom, current_bbox.b)
|
|
|
|
merged_text: str = separator.join(merged_text_parts)
|
|
merged_orig: str = separator.join(merged_orig_parts)
|
|
|
|
merged_bbox = BoundingBox(
|
|
l=min_left,
|
|
t=min_top,
|
|
r=max_right,
|
|
b=max_bottom,
|
|
coord_origin=CoordOrigin.TOPLEFT,
|
|
)
|
|
|
|
merged_polygon: List[List[float]] = create_polygon_from_bbox(merged_bbox)
|
|
merged_rect = BoundingRectangle.from_bounding_box(merged_bbox)
|
|
|
|
total_weight = sum(getattr(w, "word_weight", 1) for w in sorted_words)
|
|
|
|
return Word(
|
|
text=merged_text,
|
|
rect=merged_rect,
|
|
orig=merged_orig,
|
|
confidence=first_word_for_metadata.confidence,
|
|
from_ocr=any(w.from_ocr for w in sorted_words),
|
|
text_direction=first_word_for_metadata.text_direction,
|
|
vertical=first_word_for_metadata.vertical,
|
|
polygon=merged_polygon,
|
|
word_weight=total_weight,
|
|
)
|
|
|
|
|
|
class _IgnoreZoneFilter:
|
|
def __init__(self) -> None:
|
|
pass
|
|
|
|
def filter_words_in_ignore_zones(
|
|
self, prediction_words: List[Word], ground_truth_words: List[Word]
|
|
) -> Tuple[List[Word], List[Word], List[Word]]:
|
|
ignore_zones: List[Word] = []
|
|
|
|
temp_ground_truth_words: List[Word] = list(ground_truth_words)
|
|
for gt_word in temp_ground_truth_words:
|
|
if gt_word.ignore_zone is True:
|
|
ignore_zones.append(gt_word)
|
|
gt_word.to_remove = True
|
|
|
|
for ignore_zone_word in ignore_zones:
|
|
self._mark_intersecting_words_for_removal(
|
|
ignore_zone_word.bbox, ground_truth_words
|
|
)
|
|
self._mark_intersecting_words_for_removal(
|
|
ignore_zone_word.bbox, prediction_words
|
|
)
|
|
|
|
filtered_ground_truth_words: List[Word] = [
|
|
word for word in ground_truth_words if not word.to_remove
|
|
]
|
|
filtered_prediction_words: List[Word] = [
|
|
word for word in prediction_words if not word.to_remove
|
|
]
|
|
|
|
return filtered_ground_truth_words, filtered_prediction_words, ignore_zones
|
|
|
|
def _mark_intersecting_words_for_removal(
|
|
self, ignore_zone_bbox: BoundingBox, words_list: List[Word]
|
|
) -> None:
|
|
for word_item in words_list:
|
|
if self._check_intersection(word_item.bbox, ignore_zone_bbox):
|
|
word_item.to_remove = True
|
|
|
|
def _check_intersection(self, bbox1: BoundingBox, bbox2: BoundingBox) -> bool:
|
|
bbox1_width: float = bbox1.width
|
|
bbox1_height: float = bbox1.height
|
|
|
|
x_overlap: float = bbox1.x_overlap_with(bbox2)
|
|
y_overlap: float = bbox1.y_overlap_with(bbox2)
|
|
|
|
x_overlap_ratio: float = 0.0 if bbox1_width == 0 else x_overlap / bbox1_width
|
|
y_overlap_ratio: float = 0.0 if bbox1_height == 0 else y_overlap / bbox1_height
|
|
|
|
if y_overlap_ratio < 0.1 or x_overlap_ratio < 0.1:
|
|
return False
|
|
else:
|
|
return True
|
|
|
|
|
|
class _IgnoreZoneFilterHWR(_IgnoreZoneFilter):
|
|
def filter_words_in_ignore_zones(
|
|
self, prediction_words: List[Word], ground_truth_words: List[Word]
|
|
) -> Tuple[List[Word], List[Word], List[Word]]:
|
|
ignore_zones: List[Word] = []
|
|
|
|
# Identify ignore zones from GT and mark them for removal from GT
|
|
for gt_word in ground_truth_words:
|
|
if gt_word.ignore_zone is True:
|
|
ignore_zones.append(gt_word)
|
|
gt_word.to_remove = True
|
|
|
|
# Remove predictions that intersect the ignore zones sufficiently (IoU-based)
|
|
for zone in ignore_zones:
|
|
zone_bbox = zone.bbox
|
|
for pred_word in prediction_words:
|
|
if self._intersect_by_iou(pred_word.bbox, zone_bbox):
|
|
pred_word.to_remove = True
|
|
|
|
filtered_ground_truth_words: List[Word] = [
|
|
w for w in ground_truth_words if not w.to_remove
|
|
]
|
|
filtered_prediction_words: List[Word] = [
|
|
w for w in prediction_words if not w.to_remove
|
|
]
|
|
|
|
return filtered_ground_truth_words, filtered_prediction_words, ignore_zones
|
|
|
|
def _intersect_by_iou(
|
|
self, bbox1: BoundingBox, bbox2: BoundingBox, iou_threshold: float = 0.3
|
|
) -> bool:
|
|
# Ratio overlaps relative to bbox1 (prediction)
|
|
x_overlap = bbox1.x_overlap_with(bbox2)
|
|
y_overlap = bbox1.y_overlap_with(bbox2)
|
|
x_overlap_ratio = 0.0 if bbox1.width == 0 else x_overlap / bbox1.width
|
|
y_overlap_ratio = 0.0 if bbox1.height == 0 else y_overlap / bbox1.height
|
|
|
|
# Near-contained special-case
|
|
if x_overlap_ratio > 0.95 and y_overlap_ratio > 0.95:
|
|
return True
|
|
|
|
intersection_area = bbox1.intersection_area_with(bbox2)
|
|
union_area = bbox1.union_area_with(bbox2)
|
|
iou = (intersection_area / union_area) if union_area > 0 else 0.0
|
|
return iou >= iou_threshold
|
|
|
|
|
|
def parse_segmented_pages(
|
|
segmented_pages_raw_data: Any, document_id: str
|
|
) -> Optional[Dict[int, SegmentedPage]]:
|
|
segmented_pages_map: Dict[int, SegmentedPage] = {}
|
|
if isinstance(segmented_pages_raw_data, (bytes, str)):
|
|
try:
|
|
segmented_pages_payload: Any = json.loads(segmented_pages_raw_data)
|
|
except json.JSONDecodeError as e:
|
|
_log.warning(
|
|
f"JSONDecodeError for doc {document_id}: {e}. Data: {str(segmented_pages_raw_data)[:200]}"
|
|
)
|
|
return None
|
|
elif isinstance(segmented_pages_raw_data, dict):
|
|
segmented_pages_payload = segmented_pages_raw_data
|
|
else:
|
|
_log.warning(
|
|
f"Unrecognized segmented_pages data format for doc {document_id}: {type(segmented_pages_raw_data)}"
|
|
)
|
|
return None
|
|
|
|
if not isinstance(segmented_pages_payload, dict):
|
|
_log.warning(
|
|
f"Expected dict payload for segmented_pages for doc {document_id}, got {type(segmented_pages_payload)}"
|
|
)
|
|
return None
|
|
|
|
for page_index_str, page_data in segmented_pages_payload.items():
|
|
try:
|
|
page_index: int = int(page_index_str)
|
|
except ValueError:
|
|
_log.warning(
|
|
f"Invalid page index string '{page_index_str}' for doc {document_id}. Skipping page."
|
|
)
|
|
continue
|
|
|
|
try:
|
|
if isinstance(page_data, dict):
|
|
segmented_pages_map[page_index] = SegmentedPage.model_validate(
|
|
page_data
|
|
)
|
|
elif isinstance(page_data, str):
|
|
segmented_pages_map[page_index] = SegmentedPage.model_validate_json(
|
|
page_data
|
|
)
|
|
elif isinstance(page_data, SegmentedPage):
|
|
segmented_pages_map[page_index] = page_data
|
|
else:
|
|
_log.warning(
|
|
f"Unrecognized page_data format for doc {document_id}, page {page_index}: {type(page_data)}"
|
|
)
|
|
continue
|
|
except Exception as e_page_val:
|
|
_log.error(
|
|
f"Error validating page data for doc {document_id}, page {page_index}: {e_page_val}"
|
|
)
|
|
traceback.print_exc()
|
|
continue
|
|
return segmented_pages_map if segmented_pages_map else None
|
|
|
|
|
|
def replace_chars_by_map(text: str, char_map: Dict[str, str]) -> str:
|
|
"""Replaces characters in a string based on a provided mapping."""
|
|
if not char_map:
|
|
return text
|
|
return "".join(char_map.get(char, char) for char in text)
|
|
|
|
|
|
def calculate_edit_distance(
|
|
str1: str, str2: str, normalize_map: Optional[Dict[str, str]] = None
|
|
) -> int:
|
|
"""Calculates the Levenshtein edit distance between two strings after optional normalization."""
|
|
str1_stripped = str1.strip()
|
|
str2_stripped = str2.strip()
|
|
|
|
map_to_use = normalize_map or {}
|
|
str1_normalized = replace_chars_by_map(str1_stripped, map_to_use)
|
|
str2_normalized = replace_chars_by_map(str2_stripped, map_to_use)
|
|
|
|
sm = edit_distance.SequenceMatcher(str1_normalized, str2_normalized)
|
|
return sm.distance()
|