mirror of
https://github.com/docling-project/docling-eval.git
synced 2026-05-17 13:10:47 +00:00
* fix: update docling prediction provider to include word cells Signed-off-by: samiullahchattha <Sami.Ullah1@ibm.com> * fix: missing parsed_page in set_word_cells method Signed-off-by: samiullahchattha <Sami.Ullah1@ibm.com> * Update docling_eval/prediction_providers/docling_provider.py Co-authored-by: Christoph Auer <60343111+cau-git@users.noreply.github.com> Signed-off-by: samiuc <sami.ullah.chat@gmail.com> * Update docling_eval/prediction_providers/docling_provider.py Co-authored-by: Christoph Auer <60343111+cau-git@users.noreply.github.com> Signed-off-by: samiuc <sami.ullah.chat@gmail.com> * fix: conditionally populate word_cells in _set_word_cells method Signed-off-by: samiullahchattha <Sami.Ullah1@ibm.com> * feat: Implement smart weighted character distribution for line text processing Signed-off-by: samiullahchattha <Sami.Ullah1@ibm.com> * fix: remove redundant field validators Signed-off-by: samiullahchattha <Sami.Ullah1@ibm.com> * refactor: replace BoundingBoxDict with BoundingBox Signed-off-by: samiullahchattha <Sami.Ullah1@ibm.com> * refactor: update BoundingBox usage in prediction providers Signed-off-by: samiullahchattha <Sami.Ullah1@ibm.com> * refactor: remove unused code Signed-off-by: samiullahchattha <Sami.Ullah1@ibm.com> * refactor: move validate_evaluation_results to test_utils Signed-off-by: samiullahchattha <Sami.Ullah1@ibm.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> Co-authored-by: Christoph Auer <60343111+cau-git@users.noreply.github.com>
524 lines
21 KiB
Python
524 lines
21 KiB
Python
import copy
|
|
import glob
|
|
import json
|
|
import logging
|
|
import traceback
|
|
from pathlib import Path
|
|
from typing import Any, Dict, List, Optional, Union
|
|
|
|
from datasets import Dataset, load_dataset
|
|
from docling_core.types.doc import CoordOrigin
|
|
from docling_core.types.doc.page import BoundingRectangle, PageGeometry, SegmentedPage
|
|
from PIL import Image, ImageDraw, ImageFont
|
|
from tqdm import tqdm
|
|
|
|
from docling_eval.datamodels.dataset_record import DatasetRecordWithPrediction
|
|
from docling_eval.datamodels.types import BenchMarkColumns, PredictionFormats
|
|
from docling_eval.evaluators.base_evaluator import BaseEvaluator
|
|
from docling_eval.evaluators.ocr.benchmark_runner import _OcrBenchmark
|
|
from docling_eval.evaluators.ocr.evaluation_models import OcrDatasetEvaluationResult
|
|
from docling_eval.evaluators.ocr.processing_utils import parse_segmented_pages
|
|
|
|
logging.basicConfig(
|
|
level=logging.INFO,
|
|
format="%(asctime)s - %(levelname)s - %(message)s",
|
|
)
|
|
_log = logging.getLogger(__name__)
|
|
|
|
|
|
class OCREvaluator(BaseEvaluator):
|
|
def __init__(
|
|
self,
|
|
intermediate_evaluations_path: Optional[Path] = None,
|
|
prediction_sources: List[PredictionFormats] = [
|
|
PredictionFormats.DOCLING_DOCUMENT
|
|
],
|
|
) -> None:
|
|
super().__init__(
|
|
intermediate_evaluations_path=intermediate_evaluations_path,
|
|
prediction_sources=prediction_sources,
|
|
supported_prediction_formats=[PredictionFormats.DOCLING_DOCUMENT],
|
|
)
|
|
|
|
def __call__(
|
|
self,
|
|
ds_path: Path,
|
|
split: str = "test",
|
|
) -> OcrDatasetEvaluationResult:
|
|
dataset_path = ds_path
|
|
data_split_name = split
|
|
ignore_zone_filter_config = "default"
|
|
use_space_for_prediction_merge = True
|
|
use_space_for_gt_merge = True
|
|
|
|
benchmark_tool = _OcrBenchmark(
|
|
model_identifier="ocr_model",
|
|
ignore_zone_filter_type=ignore_zone_filter_config,
|
|
add_space_for_merged_prediction_words=use_space_for_prediction_merge,
|
|
add_space_for_merged_gt_words=use_space_for_gt_merge,
|
|
)
|
|
|
|
_log.info("Loading data split '%s' from: '%s'", data_split_name, dataset_path)
|
|
path_to_split_files = str(dataset_path / data_split_name / "*.parquet")
|
|
dataset_files = glob.glob(path_to_split_files)
|
|
if not dataset_files:
|
|
_log.warning(
|
|
"No parquet files found for split '%s' in '%s'",
|
|
data_split_name,
|
|
dataset_path,
|
|
)
|
|
return OcrDatasetEvaluationResult()
|
|
|
|
_log.info(
|
|
"Found %d files for processing: %s", len(dataset_files), dataset_files
|
|
)
|
|
hf_dataset = load_dataset(
|
|
"parquet", data_files={data_split_name: dataset_files}
|
|
)
|
|
_log.info("Dataset overview: %s", hf_dataset)
|
|
|
|
selected_dataset_split: Dataset = hf_dataset[data_split_name]
|
|
processed_item_count = 0
|
|
|
|
empty_bounding_rect = BoundingRectangle(
|
|
r_x0=0,
|
|
r_y0=0,
|
|
r_x1=0,
|
|
r_y1=0,
|
|
r_x2=0,
|
|
r_y2=0,
|
|
r_x3=0,
|
|
r_y3=0,
|
|
coord_origin=CoordOrigin.TOPLEFT,
|
|
)
|
|
empty_page_dims = PageGeometry(angle=0, rect=empty_bounding_rect)
|
|
|
|
for i, data_row in tqdm(
|
|
enumerate(selected_dataset_split),
|
|
desc="Evaluating OCR performance",
|
|
ncols=120,
|
|
total=len(selected_dataset_split),
|
|
):
|
|
if BenchMarkColumns.DOC_ID not in data_row:
|
|
_log.warning(
|
|
f"Skipping item {i} due to missing '{BenchMarkColumns.DOC_ID}' column."
|
|
)
|
|
continue
|
|
|
|
# NOTE: Somehow the validation of the data record is not working as expected
|
|
# try:
|
|
# data_record = DatasetRecordWithPrediction.model_validate(data_row)
|
|
# except Exception as e:
|
|
# _log.error("Failed to validate record %d: %s. Data: %s", i, e, data_row)
|
|
# raise RuntimeError(
|
|
# f"Failed to validate record {i}: {e}. Data: {data_row}"
|
|
# )
|
|
|
|
# doc_id = data_record.doc_id
|
|
|
|
# if data_record.status not in self._accepted_status:
|
|
# _log.warning(
|
|
# "Skipping record %s due to status: %s", doc_id, data_record.status
|
|
# )
|
|
# continue
|
|
|
|
# true_segpages = data_record.ground_truth_segmented_pages
|
|
# pred_segpages = data_record.predicted_segmented_pages
|
|
|
|
document_id: str = data_row[BenchMarkColumns.DOC_ID]
|
|
gt_page_data: SegmentedPage = SegmentedPage(dimension=empty_page_dims)
|
|
pred_page_data: SegmentedPage = SegmentedPage(dimension=empty_page_dims)
|
|
|
|
page_identifier_for_benchmark: str = document_id
|
|
|
|
gt_seg_pages_key = BenchMarkColumns.GROUNDTRUTH_SEGMENTED_PAGES
|
|
|
|
if gt_seg_pages_key in data_row and data_row[gt_seg_pages_key]:
|
|
try:
|
|
gt_pages_map: Optional[Dict[int, SegmentedPage]] = (
|
|
parse_segmented_pages(data_row[gt_seg_pages_key], document_id)
|
|
)
|
|
if gt_pages_map:
|
|
first_page_idx_gt: int = sorted(gt_pages_map.keys())[0]
|
|
gt_page_data = gt_pages_map[first_page_idx_gt]
|
|
page_identifier_for_benchmark = (
|
|
f"{document_id}_p{first_page_idx_gt}"
|
|
)
|
|
else:
|
|
_log.debug(
|
|
f"No valid GT segmented pages for {document_id}, using default empty page."
|
|
)
|
|
except Exception as e:
|
|
_log.error(
|
|
f"Error processing GT for {document_id}: {e}, using default. Trace: {traceback.format_exc()}"
|
|
)
|
|
|
|
pred_seg_pages_key = BenchMarkColumns.PREDICTED_SEGMENTED_PAGES
|
|
if pred_seg_pages_key in data_row and data_row[pred_seg_pages_key]:
|
|
try:
|
|
pred_pages_map: Optional[Dict[int, SegmentedPage]] = (
|
|
parse_segmented_pages(data_row[pred_seg_pages_key], document_id)
|
|
)
|
|
if pred_pages_map:
|
|
first_page_idx_pred: int = sorted(pred_pages_map.keys())[0]
|
|
pred_page_data = pred_pages_map[first_page_idx_pred]
|
|
if page_identifier_for_benchmark == document_id:
|
|
page_identifier_for_benchmark = (
|
|
f"{document_id}_p{first_page_idx_pred}"
|
|
)
|
|
else:
|
|
_log.debug(
|
|
f"No valid Prediction segmented pages for {document_id}, using default empty page."
|
|
)
|
|
except Exception as e:
|
|
_log.error(
|
|
f"Error processing Prediction for {document_id}: {e}, using default. Trace: {traceback.format_exc()}"
|
|
)
|
|
|
|
benchmark_tool.process_single_page_pair(
|
|
ground_truth_page=gt_page_data,
|
|
prediction_page=pred_page_data,
|
|
image_identifier=page_identifier_for_benchmark,
|
|
)
|
|
processed_item_count += 1
|
|
|
|
overall_evaluation_results = OcrDatasetEvaluationResult()
|
|
if processed_item_count > 0:
|
|
_log.info(f"Processed {processed_item_count} documents for OCR benchmark.")
|
|
formatted_summary: List[Dict[str, Any]] = (
|
|
benchmark_tool.get_formatted_metrics_summary(float_precision=1)
|
|
)
|
|
_log.info("\nAggregated OCR Metrics:")
|
|
_log.info(json.dumps(formatted_summary, indent=2))
|
|
|
|
if (
|
|
formatted_summary
|
|
and isinstance(formatted_summary, list)
|
|
and len(formatted_summary) > 0
|
|
):
|
|
metrics_from_summary: Dict[str, Any] = formatted_summary[0]
|
|
if isinstance(metrics_from_summary, dict):
|
|
overall_evaluation_results = OcrDatasetEvaluationResult(
|
|
f1_score=metrics_from_summary.get("F1", 0.0),
|
|
recall=metrics_from_summary.get("Recall", 0.0),
|
|
precision=metrics_from_summary.get("Precision", 0.0),
|
|
)
|
|
else:
|
|
_log.warning("No documents were processed for the OCR benchmark.")
|
|
|
|
_log.info(f"Final Dataset F1 Score: {overall_evaluation_results.f1_score:.4f}")
|
|
_log.info(
|
|
f"Final Dataset Precision: {overall_evaluation_results.precision:.4f}"
|
|
)
|
|
_log.info(f"Final Dataset Recall: {overall_evaluation_results.recall:.4f}")
|
|
|
|
return overall_evaluation_results
|
|
|
|
|
|
class OCRVisualizer:
|
|
def __init__(self) -> None:
|
|
self._outline_thickness: int = 2
|
|
self._ground_truth_color: str = "green"
|
|
self._prediction_color: str = "red"
|
|
self._correct_match_color: str = "blue"
|
|
self._text_label_color: str = "black"
|
|
self._visualization_subdir_name: str = "ocr_visualizations"
|
|
|
|
self._default_font: Any = ImageFont.load_default()
|
|
try:
|
|
self._rendering_font: ImageFont.FreeTypeFont = ImageFont.truetype(
|
|
"arial.ttf", size=10
|
|
)
|
|
except IOError:
|
|
self._rendering_font = self._default_font # type: ignore
|
|
|
|
def __call__(
|
|
self,
|
|
dataset_path: Path,
|
|
ocr_evaluation_report_path: Optional[Path] = None,
|
|
output_directory: Path = Path("./visual_output"),
|
|
data_split_name: str = "test",
|
|
) -> List[Path]:
|
|
visualizations_output_path: Path = (
|
|
output_directory / self._visualization_subdir_name
|
|
)
|
|
visualizations_output_path.mkdir(parents=True, exist_ok=True)
|
|
|
|
path_to_parquet_files: str = str(dataset_path / data_split_name / "*.parquet")
|
|
hf_dataset: Dataset = load_dataset(
|
|
"parquet", data_files={data_split_name: path_to_parquet_files}
|
|
)
|
|
|
|
generated_visualization_paths: List[Path] = []
|
|
if hf_dataset and data_split_name in hf_dataset:
|
|
dataset_for_visualization: Dataset = hf_dataset[data_split_name]
|
|
|
|
for i, data_row in tqdm(
|
|
enumerate(dataset_for_visualization),
|
|
desc="Generating OCR visualizations",
|
|
ncols=120,
|
|
total=len(dataset_for_visualization),
|
|
):
|
|
doc_id_val: str = data_row[BenchMarkColumns.DOC_ID]
|
|
page_images_data: Any = data_row.get(
|
|
BenchMarkColumns.GROUNDTRUTH_PAGE_IMAGES
|
|
)
|
|
|
|
ground_truth_segmented_pages: Dict[int, SegmentedPage] = {}
|
|
prediction_segmented_pages: Dict[int, SegmentedPage] = {}
|
|
|
|
gt_col_name: str = BenchMarkColumns.GROUNDTRUTH_SEGMENTED_PAGES
|
|
if gt_col_name in data_row and data_row[gt_col_name]:
|
|
parsed_gt_pages: Optional[Dict[int, SegmentedPage]] = (
|
|
parse_segmented_pages(data_row[gt_col_name], doc_id_val)
|
|
)
|
|
if parsed_gt_pages:
|
|
ground_truth_segmented_pages = parsed_gt_pages
|
|
|
|
pred_col_name: str = BenchMarkColumns.PREDICTED_SEGMENTED_PAGES
|
|
if pred_col_name in data_row and data_row[pred_col_name]:
|
|
parsed_pred_pages: Optional[Dict[int, SegmentedPage]] = (
|
|
parse_segmented_pages(data_row[pred_col_name], doc_id_val)
|
|
)
|
|
if parsed_pred_pages:
|
|
prediction_segmented_pages = parsed_pred_pages
|
|
|
|
image_item: Union[dict, Image.Image] = page_images_data[0]
|
|
if isinstance(image_item, dict):
|
|
base_image: Image.Image = image_item["image"]
|
|
else:
|
|
base_image = image_item
|
|
if base_image.mode != "RGB":
|
|
base_image = base_image.convert("RGB")
|
|
|
|
comparison_image: Image.Image = self._render_ocr_comparison_on_image(
|
|
doc_id_val,
|
|
base_image,
|
|
ground_truth_segmented_pages,
|
|
prediction_segmented_pages,
|
|
)
|
|
output_image_path: Path = (
|
|
visualizations_output_path / f"{doc_id_val}_ocr_comparison.png"
|
|
)
|
|
generated_visualization_paths.append(output_image_path)
|
|
comparison_image.save(output_image_path)
|
|
else:
|
|
_log.warning(
|
|
f"Dataset or split '{data_split_name}' not found. No visualizations will be generated."
|
|
)
|
|
|
|
return generated_visualization_paths
|
|
|
|
def _render_ocr_comparison_on_image(
|
|
self,
|
|
doc_id: str,
|
|
source_page_image: Image.Image,
|
|
ground_truth_pages: Dict[int, SegmentedPage],
|
|
prediction_pages: Dict[int, SegmentedPage],
|
|
) -> Image.Image:
|
|
gt_image_canvas: Image.Image = copy.deepcopy(source_page_image)
|
|
pred_image_canvas: Image.Image = copy.deepcopy(source_page_image)
|
|
|
|
gt_draw_context: ImageDraw.ImageDraw = ImageDraw.Draw(gt_image_canvas)
|
|
pred_draw_context: ImageDraw.ImageDraw = ImageDraw.Draw(pred_image_canvas)
|
|
|
|
if not ground_truth_pages:
|
|
_log.debug(
|
|
f"No ground truth segmented pages provided for doc {doc_id} for drawing."
|
|
)
|
|
|
|
page_index_for_drawing: int = -1
|
|
if ground_truth_pages:
|
|
page_index_for_drawing = sorted(list(ground_truth_pages.keys()))[0]
|
|
elif prediction_pages:
|
|
page_index_for_drawing = sorted(list(prediction_pages.keys()))[0]
|
|
|
|
gt_page_to_draw: Optional[SegmentedPage] = (
|
|
ground_truth_pages.get(page_index_for_drawing)
|
|
if page_index_for_drawing != -1
|
|
else None
|
|
)
|
|
pred_page_to_draw: Optional[SegmentedPage] = (
|
|
prediction_pages.get(page_index_for_drawing)
|
|
if page_index_for_drawing != -1
|
|
else None
|
|
)
|
|
|
|
page_h: float = 0.0
|
|
page_w: float = 0.0
|
|
|
|
if gt_page_to_draw:
|
|
page_h = gt_page_to_draw.dimension.height
|
|
page_w = gt_page_to_draw.dimension.width
|
|
elif pred_page_to_draw:
|
|
page_h = pred_page_to_draw.dimension.height
|
|
page_w = pred_page_to_draw.dimension.width
|
|
|
|
if page_w == 0 or page_h == 0:
|
|
page_w = float(source_page_image.width)
|
|
page_h = float(source_page_image.height)
|
|
|
|
scale_factor_x: float = source_page_image.width / page_w if page_w > 0 else 1.0
|
|
scale_factor_y: float = source_page_image.height / page_h if page_h > 0 else 1.0
|
|
|
|
if gt_page_to_draw and gt_page_to_draw.has_words:
|
|
for cell_item in gt_page_to_draw.word_cells:
|
|
bbox_obj = cell_item.rect.to_bounding_box()
|
|
if bbox_obj.coord_origin != CoordOrigin.TOPLEFT:
|
|
bbox_obj = bbox_obj.to_top_left_origin(page_height=page_h)
|
|
|
|
l_coord, t_coord = round(bbox_obj.l * scale_factor_x), round(
|
|
bbox_obj.t * scale_factor_y
|
|
)
|
|
r_coord, b_coord = round(bbox_obj.r * scale_factor_x), round(
|
|
bbox_obj.b * scale_factor_y
|
|
)
|
|
|
|
gt_draw_context.rectangle(
|
|
[l_coord, t_coord, r_coord, b_coord],
|
|
outline=self._ground_truth_color,
|
|
width=self._outline_thickness,
|
|
)
|
|
text_y_pos = t_coord - 15 if t_coord > 15 else b_coord + 2
|
|
gt_draw_context.text(
|
|
(l_coord, text_y_pos),
|
|
cell_item.text,
|
|
fill=self._text_label_color,
|
|
font=self._rendering_font,
|
|
)
|
|
|
|
if pred_page_to_draw and pred_page_to_draw.has_words:
|
|
for cell_item in pred_page_to_draw.word_cells:
|
|
bbox_obj = cell_item.rect.to_bounding_box()
|
|
if bbox_obj.coord_origin != CoordOrigin.TOPLEFT:
|
|
bbox_obj = bbox_obj.to_top_left_origin(page_height=page_h)
|
|
|
|
l_coord, t_coord = round(bbox_obj.l * scale_factor_x), round(
|
|
bbox_obj.t * scale_factor_y
|
|
)
|
|
r_coord, b_coord = round(bbox_obj.r * scale_factor_x), round(
|
|
bbox_obj.b * scale_factor_y
|
|
)
|
|
|
|
is_match_correct: bool = False
|
|
if gt_page_to_draw and gt_page_to_draw.has_words:
|
|
for gt_cell_item in gt_page_to_draw.word_cells:
|
|
if gt_cell_item.text == cell_item.text:
|
|
gt_bbox_obj = gt_cell_item.rect.to_bounding_box()
|
|
if gt_bbox_obj.coord_origin != CoordOrigin.TOPLEFT:
|
|
gt_bbox_obj = gt_bbox_obj.to_top_left_origin(
|
|
page_height=page_h
|
|
)
|
|
|
|
if not (
|
|
l_coord > round(gt_bbox_obj.r * scale_factor_x)
|
|
or r_coord < round(gt_bbox_obj.l * scale_factor_x)
|
|
or t_coord > round(gt_bbox_obj.b * scale_factor_y)
|
|
or b_coord < round(gt_bbox_obj.t * scale_factor_y)
|
|
):
|
|
is_match_correct = True
|
|
break
|
|
|
|
box_draw_color: str = (
|
|
self._correct_match_color
|
|
if is_match_correct
|
|
else self._prediction_color
|
|
)
|
|
pred_draw_context.rectangle(
|
|
[l_coord, t_coord, r_coord, b_coord],
|
|
outline=box_draw_color,
|
|
width=self._outline_thickness,
|
|
)
|
|
text_y_pos = t_coord - 15 if t_coord > 15 else b_coord + 2
|
|
pred_draw_context.text(
|
|
(l_coord, text_y_pos),
|
|
cell_item.text,
|
|
fill=self._text_label_color,
|
|
font=self._rendering_font,
|
|
)
|
|
|
|
img_mode: str = source_page_image.mode
|
|
img_w, img_h = source_page_image.size
|
|
stitched_image: Image.Image = Image.new(img_mode, (2 * img_w, img_h), "white")
|
|
stitched_image.paste(gt_image_canvas, (0, 0))
|
|
stitched_image.paste(pred_image_canvas, (img_w, 0))
|
|
|
|
stitched_draw_context: ImageDraw.ImageDraw = ImageDraw.Draw(stitched_image)
|
|
header_font_size: int = max(15, int(img_h * 0.02))
|
|
sub_header_font_size: int = max(12, int(img_h * 0.015))
|
|
|
|
try:
|
|
title_text_font = ImageFont.truetype("arial.ttf", size=header_font_size)
|
|
legend_text_font = ImageFont.truetype(
|
|
"arial.ttf", size=sub_header_font_size
|
|
)
|
|
except IOError:
|
|
title_text_font = self._default_font
|
|
legend_text_font = self._default_font
|
|
|
|
stitched_draw_context.text(
|
|
(10, 10), "Ground Truth OCR", fill="black", font=title_text_font
|
|
)
|
|
stitched_draw_context.text(
|
|
(img_w + 10, 10), "Predicted OCR", fill="black", font=title_text_font
|
|
)
|
|
|
|
legend_start_y: int = header_font_size + 20
|
|
legend_rect_dim: int = sub_header_font_size
|
|
legend_item_gap: int = int(sub_header_font_size * 0.5)
|
|
|
|
stitched_draw_context.rectangle(
|
|
[
|
|
10,
|
|
legend_start_y,
|
|
10 + legend_rect_dim,
|
|
legend_start_y + legend_rect_dim,
|
|
],
|
|
outline=self._ground_truth_color,
|
|
fill=self._ground_truth_color,
|
|
)
|
|
stitched_draw_context.text(
|
|
(15 + legend_rect_dim, legend_start_y),
|
|
"Ground Truth Word",
|
|
fill="black",
|
|
font=legend_text_font,
|
|
)
|
|
|
|
current_pred_legend_y = legend_start_y
|
|
stitched_draw_context.rectangle(
|
|
[
|
|
img_w + 10,
|
|
current_pred_legend_y,
|
|
img_w + 10 + legend_rect_dim,
|
|
current_pred_legend_y + legend_rect_dim,
|
|
],
|
|
outline=self._correct_match_color,
|
|
fill=self._correct_match_color,
|
|
)
|
|
stitched_draw_context.text(
|
|
(img_w + 15 + legend_rect_dim, current_pred_legend_y),
|
|
"Correct Prediction",
|
|
fill="black",
|
|
font=legend_text_font,
|
|
)
|
|
|
|
current_pred_legend_y += legend_rect_dim + legend_item_gap
|
|
stitched_draw_context.rectangle(
|
|
[
|
|
img_w + 10,
|
|
current_pred_legend_y,
|
|
img_w + 10 + legend_rect_dim,
|
|
current_pred_legend_y + legend_rect_dim,
|
|
],
|
|
outline=self._prediction_color,
|
|
fill=self._prediction_color,
|
|
)
|
|
stitched_draw_context.text(
|
|
(img_w + 15 + legend_rect_dim, current_pred_legend_y),
|
|
"Incorrect Prediction",
|
|
fill="black",
|
|
font=legend_text_font,
|
|
)
|
|
|
|
return stitched_image
|