From 60f0fb541d03e33817500574c57f5dd0322be120 Mon Sep 17 00:00:00 2001 From: vahidrezanezhad Date: Tue, 3 Feb 2026 19:45:50 +0100 Subject: [PATCH 01/77] integrating transformer ocr --- src/eynollah/training/train.py | 130 +++++++++- src/eynollah/training/utils.py | 417 ++++++++++++++++++++++++++++++++- train/config_params.json | 13 +- train/requirements.txt | 5 + 4 files changed, 552 insertions(+), 13 deletions(-) diff --git a/src/eynollah/training/train.py b/src/eynollah/training/train.py index 7a0cb3d..7ed8282 100644 --- a/src/eynollah/training/train.py +++ b/src/eynollah/training/train.py @@ -1,7 +1,8 @@ import os import sys import json - +import numpy as np +import cv2 import click from eynollah.training.metrics import ( @@ -27,7 +28,8 @@ from eynollah.training.utils import ( generate_data_from_folder_training, get_one_hot, provide_patches, - return_number_of_total_training_data + return_number_of_total_training_data, + OCRDatasetYieldAugmentations ) os.environ['TF_CPP_MIN_LOG_LEVEL'] = '3' @@ -41,8 +43,13 @@ from sklearn.metrics import f1_score from tensorflow.keras.callbacks import Callback from tensorflow.keras.layers import StringLookup -import numpy as np -import cv2 + +import torch +from transformers import TrOCRProcessor +import evaluate +from transformers import default_data_collator +from transformers import VisionEncoderDecoderModel +from transformers import Seq2SeqTrainer, Seq2SeqTrainingArguments class SaveWeightsAfterSteps(Callback): def __init__(self, save_interval, save_path, _config): @@ -559,6 +566,121 @@ def run( with open(os.path.join(os.path.join(dir_output,'model_'+str(i)),"config.json"), "w") as fp: json.dump(_config, fp) # encode dict into JSON + + elif task=="transformer-ocr": + dir_img, dir_lab = get_dirs_or_files(dir_train) + + processor = TrOCRProcessor.from_pretrained("microsoft/trocr-base-printed") + + ls_files_images = os.listdir(dir_img) + + aug_multip = return_multiplier_based_on_augmnentations(augmentation, color_padding_rotation, rotation_not_90, blur_aug, degrading, bin_deg, + brightening, padding_white, adding_rgb_foreground, adding_rgb_background, binarization, + image_inversion, channels_shuffling, add_red_textlines, white_noise_strap, textline_skewing, textline_skewing_bin, textline_left_in_depth, textline_left_in_depth_bin, textline_right_in_depth, textline_right_in_depth_bin, textline_up_in_depth, textline_up_in_depth_bin, textline_down_in_depth, textline_down_in_depth_bin, pepper_bin_aug, pepper_aug, degrade_scales, number_of_backgrounds_per_image, thetha, thetha_padd, brightness, padd_colors, shuffle_indexes, pepper_indexes, skewing_amplitudes, blur_k, white_padds) + + len_dataset = aug_multip*len(ls_files_images) + + dataset = OCRDatasetYieldAugmentations( + dir_img=dir_img, + dir_img_bin=dir_img_bin, + dir_lab=dir_lab, + processor=processor, + max_target_length=max_len, + augmentation = augmentation, + binarization = binarization, + add_red_textlines = add_red_textlines, + white_noise_strap = white_noise_strap, + adding_rgb_foreground = adding_rgb_foreground, + adding_rgb_background = adding_rgb_background, + bin_deg = bin_deg, + blur_aug = blur_aug, + brightening = brightening, + padding_white = padding_white, + color_padding_rotation = color_padding_rotation, + rotation_not_90 = rotation_not_90, + degrading = degrading, + channels_shuffling = channels_shuffling, + textline_skewing = textline_skewing, + textline_skewing_bin = textline_skewing_bin, + textline_right_in_depth = textline_right_in_depth, + textline_left_in_depth = textline_left_in_depth, + textline_up_in_depth = textline_up_in_depth, + textline_down_in_depth = textline_down_in_depth, + textline_right_in_depth_bin = textline_right_in_depth_bin, + textline_left_in_depth_bin = textline_left_in_depth_bin, + textline_up_in_depth_bin = textline_up_in_depth_bin, + textline_down_in_depth_bin = textline_down_in_depth_bin, + pepper_aug = pepper_aug, + pepper_bin_aug = pepper_bin_aug, + list_all_possible_background_images=list_all_possible_background_images, + list_all_possible_foreground_rgbs=list_all_possible_foreground_rgbs, + blur_k = blur_k, + degrade_scales = degrade_scales, + white_padds = white_padds, + thetha_padd = thetha_padd, + thetha = thetha, + brightness = brightness, + padd_colors = padd_colors, + number_of_backgrounds_per_image = number_of_backgrounds_per_image, + shuffle_indexes = shuffle_indexes, + pepper_indexes = pepper_indexes, + skewing_amplitudes = skewing_amplitudes, + dir_rgb_backgrounds = dir_rgb_backgrounds, + dir_rgb_foregrounds = dir_rgb_foregrounds, + len_data=len_dataset, + ) + + # Create a DataLoader + data_loader = torch.utils.data.DataLoader(dataset, batch_size=1) + train_dataset = data_loader.dataset + + + if continue_training: + model = VisionEncoderDecoderModel.from_pretrained(dir_of_start_model) + else: + model = VisionEncoderDecoderModel.from_pretrained("microsoft/trocr-base-printed") + + + # set special tokens used for creating the decoder_input_ids from the labels + model.config.decoder_start_token_id = processor.tokenizer.cls_token_id + model.config.pad_token_id = processor.tokenizer.pad_token_id + # make sure vocab size is set correctly + model.config.vocab_size = model.config.decoder.vocab_size + + # set beam search parameters + model.config.eos_token_id = processor.tokenizer.sep_token_id + model.config.max_length = max_len + model.config.early_stopping = True + model.config.no_repeat_ngram_size = 3 + model.config.length_penalty = 2.0 + model.config.num_beams = 4 + + + training_args = Seq2SeqTrainingArguments( + predict_with_generate=True, + num_train_epochs=n_epochs, + learning_rate=learning_rate, + per_device_train_batch_size=n_batch, + fp16=True, + output_dir=dir_output, + logging_steps=2, + save_steps=save_interval, + ) + + + cer_metric = evaluate.load("cer") + + # instantiate trainer + trainer = Seq2SeqTrainer( + model=model, + tokenizer=processor.feature_extractor, + args=training_args, + train_dataset=train_dataset, + data_collator=default_data_collator, + ) + trainer.train() + + elif task=='classification': configuration() model = resnet50_classifier(n_classes, input_height, input_width, weight_decay, pretraining) diff --git a/src/eynollah/training/utils.py b/src/eynollah/training/utils.py index 005810f..9b4e01a 100644 --- a/src/eynollah/training/utils.py +++ b/src/eynollah/training/utils.py @@ -9,12 +9,16 @@ from scipy.ndimage.interpolation import map_coordinates from scipy.ndimage.filters import gaussian_filter from tqdm import tqdm import imutils -import tensorflow as tf -from tensorflow.keras.utils import to_categorical +##import tensorflow as tf +##from tensorflow.keras.utils import to_categorical from PIL import Image, ImageFile, ImageEnhance +import torch +from torch.utils.data import IterableDataset + ImageFile.LOAD_TRUNCATED_IMAGES = True + def vectorize_label(label, char_to_num, padding_token, max_len): label = char_to_num(tf.strings.unicode_split(label, input_encoding="UTF-8")) length = tf.shape(label)[0] @@ -76,6 +80,7 @@ def add_salt_and_pepper_noise(img, salt_prob, pepper_prob): return noisy_image + def invert_image(img): img_inv = 255 - img return img_inv @@ -1668,3 +1673,411 @@ def return_multiplier_based_on_augmnentations( aug_multip += len(pepper_indexes) return aug_multip + + +class OCRDatasetYieldAugmentations(IterableDataset): + def __init__( + self, + dir_img, + dir_img_bin, + dir_lab, + processor, + max_target_length=128, + augmentation = None, + binarization = None, + add_red_textlines = None, + white_noise_strap = None, + adding_rgb_foreground = None, + adding_rgb_background = None, + bin_deg = None, + blur_aug = None, + brightening = None, + padding_white = None, + color_padding_rotation = None, + rotation_not_90 = None, + degrading = None, + channels_shuffling = None, + textline_skewing = None, + textline_skewing_bin = None, + textline_right_in_depth = None, + textline_left_in_depth = None, + textline_up_in_depth = None, + textline_down_in_depth = None, + textline_right_in_depth_bin = None, + textline_left_in_depth_bin = None, + textline_up_in_depth_bin = None, + textline_down_in_depth_bin = None, + pepper_aug = None, + pepper_bin_aug = None, + list_all_possible_background_images=None, + list_all_possible_foreground_rgbs=None, + blur_k = None, + degrade_scales = None, + white_padds = None, + thetha_padd = None, + thetha = None, + brightness = None, + padd_colors = None, + number_of_backgrounds_per_image = None, + shuffle_indexes = None, + pepper_indexes = None, + skewing_amplitudes = None, + dir_rgb_backgrounds = None, + dir_rgb_foregrounds = None, + len_data=None, + ): + """ + Args: + images_dir (str): Path to the directory containing images. + labels_dir (str): Path to the directory containing label text files. + tokenizer: Tokenizer for processing labels. + transform: Transformations applied after augmentation (e.g., ToTensor, normalization). + image_size (tuple): Size to resize images to. + max_seq_len (int): Maximum sequence length for tokenized labels. + scales (list or None): List of scale factors to apply. + """ + self.dir_img = dir_img + self.dir_img_bin = dir_img_bin + self.dir_lab = dir_lab + self.processor = processor + self.max_target_length = max_target_length + #self.scales = scales if scales else [] + + self.augmentation = augmentation + self.binarization = binarization + self.add_red_textlines = add_red_textlines + self.white_noise_strap = white_noise_strap + self.adding_rgb_foreground = adding_rgb_foreground + self.adding_rgb_background = adding_rgb_background + self.bin_deg = bin_deg + self.blur_aug = blur_aug + self.brightening = brightening + self.padding_white = padding_white + self.color_padding_rotation = color_padding_rotation + self.rotation_not_90 = rotation_not_90 + self.degrading = degrading + self.channels_shuffling = channels_shuffling + self.textline_skewing = textline_skewing + self.textline_skewing_bin = textline_skewing_bin + self.textline_right_in_depth = textline_right_in_depth + self.textline_left_in_depth = textline_left_in_depth + self.textline_up_in_depth = textline_up_in_depth + self.textline_down_in_depth = textline_down_in_depth + self.textline_right_in_depth_bin = textline_right_in_depth_bin + self.textline_left_in_depth_bin = textline_left_in_depth_bin + self.textline_up_in_depth_bin = textline_up_in_depth_bin + self.textline_down_in_depth_bin = textline_down_in_depth_bin + self.pepper_aug = pepper_aug + self.pepper_bin_aug = pepper_bin_aug + self.list_all_possible_background_images=list_all_possible_background_images + self.list_all_possible_foreground_rgbs=list_all_possible_foreground_rgbs + self.blur_k = blur_k + self.degrade_scales = degrade_scales + self.white_padds = white_padds + self.thetha_padd = thetha_padd + self.thetha = thetha + self.brightness = brightness + self.padd_colors = padd_colors + self.number_of_backgrounds_per_image = number_of_backgrounds_per_image + self.shuffle_indexes = shuffle_indexes + self.pepper_indexes = pepper_indexes + self.skewing_amplitudes = skewing_amplitudes + self.dir_rgb_backgrounds = dir_rgb_backgrounds + self.dir_rgb_foregrounds = dir_rgb_foregrounds + self.image_files = os.listdir(dir_img)#sorted([f for f in os.listdir(images_dir) if f.lower().endswith(('.png', '.jpg', '.jpeg'))]) + self.len_data = len_data + #assert len(self.image_files) == len(self.label_files), "Number of images and labels must match!" + + def __len__(self): + return self.len_data + + def __iter__(self): + for img_file in self.image_files: + # Load image + f_name = img_file.split('.')[0] + + txt_inp = open(os.path.join(self.dir_lab, f_name+'.txt'),'r').read().split('\n')[0] + + img = cv2.imread(os.path.join(self.dir_img, img_file)) + img = img.astype(np.uint8) + + + if self.dir_img_bin: + img_bin_corr = cv2.imread(os.path.join(self.dir_img_bin, f_name+'.png') ) + img_bin_corr = img_bin_corr.astype(np.uint8) + else: + img_bin_corr = None + + + labels = self.processor.tokenizer(txt_inp, + padding="max_length", + max_length=self.max_target_length).input_ids + + labels = [label if label != self.processor.tokenizer.pad_token_id else -100 for label in labels] + + + if self.augmentation: + pixel_values = self.processor(Image.fromarray(img), return_tensors="pt").pixel_values + encoding = {"pixel_values": pixel_values.squeeze(), "labels": torch.tensor(labels)} + yield encoding + + + if self.color_padding_rotation: + for index, thetha_ind in enumerate(self.thetha_padd): + for padd_col in self.padd_colors: + img_out = rotation_not_90_func_single_image(do_padding_for_ocr(img, 1.2, padd_col), thetha_ind) + img_out = img_out.astype(np.uint8) + pixel_values = self.processor(Image.fromarray(img_out), return_tensors="pt").pixel_values + encoding = {"pixel_values": pixel_values.squeeze(), "labels": torch.tensor(labels)} + yield encoding + + if self.rotation_not_90: + for index, thetha_ind in enumerate(self.thetha): + img_out = rotation_not_90_func_single_image(img, thetha_ind) + img_out = img_out.astype(np.uint8) + pixel_values = self.processor(Image.fromarray(img_out), return_tensors="pt").pixel_values + encoding = {"pixel_values": pixel_values.squeeze(), "labels": torch.tensor(labels)} + yield encoding + + + if self.blur_aug: + for index, blur_type in enumerate(self.blur_k): + img_out = bluring(img, blur_type) + img_out = img_out.astype(np.uint8) + pixel_values = self.processor(Image.fromarray(img_out), return_tensors="pt").pixel_values + encoding = {"pixel_values": pixel_values.squeeze(), "labels": torch.tensor(labels)} + yield encoding + + if self.degrading: + for index, deg_scale_ind in enumerate(self.degrade_scales): + try: + img_out = do_degrading(img, deg_scale_ind) + except: + img_out = np.copy(img) + img_out = img_out.astype(np.uint8) + pixel_values = self.processor(Image.fromarray(img_out), return_tensors="pt").pixel_values + encoding = {"pixel_values": pixel_values.squeeze(), "labels": torch.tensor(labels)} + yield encoding + + if self.bin_deg: + for index, deg_scale_ind in enumerate(self.degrade_scales): + try: + img_out = self.do_degrading(img_bin_corr, deg_scale_ind) + except: + img_out = np.copy(img_bin_corr) + img_out = img_out.astype(np.uint8) + pixel_values = self.processor(Image.fromarray(img_out), return_tensors="pt").pixel_values + encoding = {"pixel_values": pixel_values.squeeze(), "labels": torch.tensor(labels)} + yield encoding + + + if self.brightening: + for index, bright_scale_ind in enumerate(self.brightness): + try: + img_out = do_brightening(dir_img, bright_scale_ind) + except: + img_out = np.copy(img) + img_out = img_out.astype(np.uint8) + pixel_values = self.processor(Image.fromarray(img_out), return_tensors="pt").pixel_values + encoding = {"pixel_values": pixel_values.squeeze(), "labels": torch.tensor(labels)} + yield encoding + + + if self.padding_white: + for index, padding_size in enumerate(self.white_padds): + for padd_col in self.padd_colors: + img_out = do_padding_for_ocr(img, padding_size, padd_col) + img_out = img_out.astype(np.uint8) + pixel_values = self.processor(Image.fromarray(img_out), return_tensors="pt").pixel_values + encoding = {"pixel_values": pixel_values.squeeze(), "labels": torch.tensor(labels)} + yield encoding + + + if self.adding_rgb_foreground: + for i_n in range(self.number_of_backgrounds_per_image): + background_image_chosen_name = random.choice(self.list_all_possible_background_images) + foreground_rgb_chosen_name = random.choice(self.list_all_possible_foreground_rgbs) + + img_rgb_background_chosen = cv2.imread(self.dir_rgb_backgrounds + '/' + background_image_chosen_name) + foreground_rgb_chosen = np.load(self.dir_rgb_foregrounds + '/' + foreground_rgb_chosen_name) + + img_out = return_binary_image_with_given_rgb_background_and_given_foreground_rgb(img_bin_corr, img_rgb_background_chosen, foreground_rgb_chosen) + img_out = img_out.astype(np.uint8) + pixel_values = self.processor(Image.fromarray(img_out), return_tensors="pt").pixel_values + encoding = {"pixel_values": pixel_values.squeeze(), "labels": torch.tensor(labels)} + yield encoding + + + + + if self.adding_rgb_background: + for i_n in range(self.number_of_backgrounds_per_image): + background_image_chosen_name = random.choice(self.list_all_possible_background_images) + img_rgb_background_chosen = cv2.imread(self.dir_rgb_backgrounds + '/' + background_image_chosen_name) + img_out = return_binary_image_with_given_rgb_background(img_bin_corr, img_rgb_background_chosen) + img_out = img_out.astype(np.uint8) + pixel_values = self.processor(Image.fromarray(img_out), return_tensors="pt").pixel_values + encoding = {"pixel_values": pixel_values.squeeze(), "labels": torch.tensor(labels)} + yield encoding + + if self.binarization: + pixel_values = self.processor(Image.fromarray(img_bin_corr), return_tensors="pt").pixel_values + encoding = {"pixel_values": pixel_values.squeeze(), "labels": torch.tensor(labels)} + yield encoding + + + if self.channels_shuffling: + for shuffle_index in self.shuffle_indexes: + img_out = return_shuffled_channels(img, shuffle_index) + img_out = img_out.astype(np.uint8) + pixel_values = self.processor(Image.fromarray(img_out), return_tensors="pt").pixel_values + encoding = {"pixel_values": pixel_values.squeeze(), "labels": torch.tensor(labels)} + yield encoding + + if self.add_red_textlines: + img_out = return_image_with_red_elements(img, img_bin_corr) + img_out = img_out.astype(np.uint8) + pixel_values = self.processor(Image.fromarray(img_out), return_tensors="pt").pixel_values + encoding = {"pixel_values": pixel_values.squeeze(), "labels": torch.tensor(labels)} + yield encoding + + if self.white_noise_strap: + img_out = return_image_with_strapped_white_noises(img) + img_out = img_out.astype(np.uint8) + pixel_values = self.processor(Image.fromarray(img_out), return_tensors="pt").pixel_values + encoding = {"pixel_values": pixel_values.squeeze(), "labels": torch.tensor(labels)} + yield encoding + + if self.textline_skewing: + for index, des_scale_ind in enumerate(self.skewing_amplitudes): + try: + img_out = do_deskewing(img, des_scale_ind) + except: + img_out = np.copy(img) + img_out = img_out.astype(np.uint8) + pixel_values = self.processor(Image.fromarray(img_out), return_tensors="pt").pixel_values + encoding = {"pixel_values": pixel_values.squeeze(), "labels": torch.tensor(labels)} + yield encoding + + if self.textline_skewing_bin: + for index, des_scale_ind in enumerate(self.skewing_amplitudes): + try: + img_out = do_deskewing(img_bin_corr, des_scale_ind) + except: + img_out = np.copy(img_bin_corr) + img_out = img_out.astype(np.uint8) + pixel_values = self.processor(Image.fromarray(img_out), return_tensors="pt").pixel_values + encoding = {"pixel_values": pixel_values.squeeze(), "labels": torch.tensor(labels)} + yield encoding + + + if self.textline_left_in_depth: + try: + img_out = do_direction_in_depth(img, 'left') + except: + img_out = np.copy(img) + img_out = img_out.astype(np.uint8) + pixel_values = self.processor(Image.fromarray(img_out), return_tensors="pt").pixel_values + encoding = {"pixel_values": pixel_values.squeeze(), "labels": torch.tensor(labels)} + yield encoding + + + if self.textline_left_in_depth_bin: + try: + img_out = do_direction_in_depth(img_bin_corr, 'left') + except: + img_out = np.copy(img_bin_corr) + img_out = img_out.astype(np.uint8) + pixel_values = self.processor(Image.fromarray(img_out), return_tensors="pt").pixel_values + encoding = {"pixel_values": pixel_values.squeeze(), "labels": torch.tensor(labels)} + yield encoding + + + if self.textline_right_in_depth: + try: + img_out = do_direction_in_depth(img, 'right') + except: + img_out = np.copy(img) + img_out = img_out.astype(np.uint8) + pixel_values = self.processor(Image.fromarray(img_out), return_tensors="pt").pixel_values + encoding = {"pixel_values": pixel_values.squeeze(), "labels": torch.tensor(labels)} + yield encoding + + + if self.textline_right_in_depth_bin: + try: + img_out = do_direction_in_depth(img_bin_corr, 'right') + except: + img_out = np.copy(img_bin_corr) + img_out = img_out.astype(np.uint8) + pixel_values = self.processor(Image.fromarray(img_out), return_tensors="pt").pixel_values + encoding = {"pixel_values": pixel_values.squeeze(), "labels": torch.tensor(labels)} + yield encoding + + + if self.textline_up_in_depth: + try: + img_out = do_direction_in_depth(img, 'up') + except: + img_out = np.copy(img) + img_out = img_out.astype(np.uint8) + pixel_values = self.processor(Image.fromarray(img_out), return_tensors="pt").pixel_values + encoding = {"pixel_values": pixel_values.squeeze(), "labels": torch.tensor(labels)} + yield encoding + + + if self.textline_up_in_depth_bin: + try: + img_out = do_direction_in_depth(img_bin_corr, 'up') + except: + img_out = np.copy(img_bin_corr) + img_out = img_out.astype(np.uint8) + pixel_values = self.processor(Image.fromarray(img_out), return_tensors="pt").pixel_values + encoding = {"pixel_values": pixel_values.squeeze(), "labels": torch.tensor(labels)} + yield encoding + + + if self.textline_down_in_depth: + try: + img_out = do_direction_in_depth(img, 'down') + except: + img_out = np.copy(img) + img_out = img_out.astype(np.uint8) + pixel_values = self.processor(Image.fromarray(img_out), return_tensors="pt").pixel_values + encoding = {"pixel_values": pixel_values.squeeze(), "labels": torch.tensor(labels)} + yield encoding + + + if self.textline_down_in_depth_bin: + try: + img_out = do_direction_in_depth(img_bin_corr, 'down') + except: + img_out = np.copy(img_bin_corr) + img_out = img_out.astype(np.uint8) + pixel_values = self.processor(Image.fromarray(img_out), return_tensors="pt").pixel_values + encoding = {"pixel_values": pixel_values.squeeze(), "labels": torch.tensor(labels)} + yield encoding + + if self.pepper_bin_aug: + for index, pepper_ind in enumerate(self.pepper_indexes): + img_out = add_salt_and_pepper_noise(img_bin_corr, pepper_ind, pepper_ind) + img_out = img_out.astype(np.uint8) + pixel_values = self.processor(Image.fromarray(img_out), return_tensors="pt").pixel_values + encoding = {"pixel_values": pixel_values.squeeze(), "labels": torch.tensor(labels)} + yield encoding + + + if self.pepper_aug: + for index, pepper_ind in enumerate(self.pepper_indexes): + img_out = add_salt_and_pepper_noise(img, pepper_ind, pepper_ind) + img_out = img_out.astype(np.uint8) + pixel_values = self.processor(Image.fromarray(img_out), return_tensors="pt").pixel_values + encoding = {"pixel_values": pixel_values.squeeze(), "labels": torch.tensor(labels)} + yield encoding + + + + else: + pixel_values = self.processor(Image.fromarray(img), return_tensors="pt").pixel_values + encoding = {"pixel_values": pixel_values.squeeze(), "labels": torch.tensor(labels)} + yield encoding diff --git a/train/config_params.json b/train/config_params.json index b01ac08..34c6376 100644 --- a/train/config_params.json +++ b/train/config_params.json @@ -1,17 +1,17 @@ { "backbone_type" : "transformer", - "task": "cnn-rnn-ocr", + "task": "transformer-ocr", "n_classes" : 2, - "max_len": 280, - "n_epochs" : 3, + "max_len": 192, + "n_epochs" : 1, "input_height" : 32, "input_width" : 512, "weight_decay" : 1e-6, - "n_batch" : 4, + "n_batch" : 1, "learning_rate": 1e-5, "save_interval": 1500, "patches" : false, - "pretraining" : true, + "pretraining" : false, "augmentation" : true, "flip_aug" : false, "blur_aug" : true, @@ -77,7 +77,6 @@ "dir_output": "/home/vahid/extracted_lines/1919_bin/output", "dir_rgb_backgrounds": "/home/vahid/Documents/1_2_test_eynollah/set_rgb_background", "dir_rgb_foregrounds": "/home/vahid/Documents/1_2_test_eynollah/out_set_rgb_foreground", - "dir_img_bin": "/home/vahid/extracted_lines/1919_bin/images_bin", - "characters_txt_file":"/home/vahid/Downloads/models_eynollah/model_eynollah_ocr_cnnrnn_20250930/characters_org.txt" + "dir_img_bin": "/home/vahid/extracted_lines/1919_bin/images_bin" } diff --git a/train/requirements.txt b/train/requirements.txt index 63f3813..e3599a8 100644 --- a/train/requirements.txt +++ b/train/requirements.txt @@ -4,3 +4,8 @@ numpy <1.24.0 tqdm imutils scipy +torch +evaluate +accelerate +jiwer +transformers <= 4.30.2 From fff42533524d3ee91d298a2873ae19957304be2e Mon Sep 17 00:00:00 2001 From: vahidrezanezhad Date: Tue, 3 Feb 2026 20:20:20 +0100 Subject: [PATCH 02/77] generate or update list of characters in the case of cnn-rnn ocr training --- src/eynollah/training/cli.py | 2 + ...te_or_update_cnn_rnn_ocr_character_list.py | 59 +++++++++++++++++++ 2 files changed, 61 insertions(+) create mode 100644 src/eynollah/training/generate_or_update_cnn_rnn_ocr_character_list.py diff --git a/src/eynollah/training/cli.py b/src/eynollah/training/cli.py index 3718275..862d212 100644 --- a/src/eynollah/training/cli.py +++ b/src/eynollah/training/cli.py @@ -10,6 +10,7 @@ from .inference import main as inference_cli from .train import ex from .extract_line_gt import linegt_cli from .weights_ensembling import main as ensemble_cli +from .generate_or_update_cnn_rnn_ocr_character_list import main as update_ocr_characters_cli @click.command(context_settings=dict( ignore_unknown_options=True, @@ -28,3 +29,4 @@ main.add_command(inference_cli, 'inference') main.add_command(train_cli, 'train') main.add_command(linegt_cli, 'export_textline_images_and_text') main.add_command(ensemble_cli, 'ensembling') +main.add_command(update_ocr_characters_cli, 'generate_or_update_cnn_rnn_ocr_character_list') diff --git a/src/eynollah/training/generate_or_update_cnn_rnn_ocr_character_list.py b/src/eynollah/training/generate_or_update_cnn_rnn_ocr_character_list.py new file mode 100644 index 0000000..6d1028c --- /dev/null +++ b/src/eynollah/training/generate_or_update_cnn_rnn_ocr_character_list.py @@ -0,0 +1,59 @@ +import os +import numpy as np +import json +import click +import logging + + + +def run_character_list_update(dir_labels, out, current_character_list): + ls_labels = os.listdir(dir_labels) + ls_labels = [ind for ind in ls_labels if ind.endswith('.txt')] + + if current_character_list: + with open(current_character_list, 'r') as f_name: + characters = json.load(f_name) + + characters = set(characters) + else: + characters = set() + + + for ind in ls_labels: + label = open(os.path.join(dir_labels,ind),'r').read().split('\n')[0] + + for char in label: + characters.add(char) + + + characters = sorted(list(set(characters))) + + with open(out, 'w') as f_name: + json.dump(characters, f_name) + + +@click.command() +@click.option( + "--dir_labels", + "-dl", + help="directory of labels which are txt files", + type=click.Path(exists=True, file_okay=False), + required=True, +) +@click.option( + "--current_character_list", + "-ccl", + help="current exsiting character list which is txt file and wished to be updated with a set of labels", + type=click.Path(exists=True, file_okay=True), + required=False, +) +@click.option( + "--out", + "-o", + help="output file which is a txt file where generated or updated character list will be written", + type=click.Path(exists=False, file_okay=True), +) + +def main(dir_labels, out, current_character_list): + run_character_list_update(dir_labels, out, current_character_list) + From 498ff8f7a5246958ffb2be970f5afdcaff2511ba Mon Sep 17 00:00:00 2001 From: vahidrezanezhad Date: Tue, 3 Feb 2026 20:27:06 +0100 Subject: [PATCH 03/77] Updating the --help descriptions --- .../generate_or_update_cnn_rnn_ocr_character_list.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/eynollah/training/generate_or_update_cnn_rnn_ocr_character_list.py b/src/eynollah/training/generate_or_update_cnn_rnn_ocr_character_list.py index 6d1028c..8620515 100644 --- a/src/eynollah/training/generate_or_update_cnn_rnn_ocr_character_list.py +++ b/src/eynollah/training/generate_or_update_cnn_rnn_ocr_character_list.py @@ -36,21 +36,21 @@ def run_character_list_update(dir_labels, out, current_character_list): @click.option( "--dir_labels", "-dl", - help="directory of labels which are txt files", + help="directory of labels which are .txt files", type=click.Path(exists=True, file_okay=False), required=True, ) @click.option( "--current_character_list", "-ccl", - help="current exsiting character list which is txt file and wished to be updated with a set of labels", + help="existing character list in a .txt file that needs to be updated with a set of labels", type=click.Path(exists=True, file_okay=True), required=False, ) @click.option( "--out", "-o", - help="output file which is a txt file where generated or updated character list will be written", + help="An output .txt file where the generated or updated character list will be written", type=click.Path(exists=False, file_okay=True), ) From fbf252db130e63b310146fa0357975814da6fd0f Mon Sep 17 00:00:00 2001 From: vahidrezanezhad Date: Wed, 4 Feb 2026 21:16:08 +0100 Subject: [PATCH 04/77] torch model ensembling is integrated --- src/eynollah/training/weights_ensembling.py | 67 ++++++++++++++------- 1 file changed, 46 insertions(+), 21 deletions(-) diff --git a/src/eynollah/training/weights_ensembling.py b/src/eynollah/training/weights_ensembling.py index 6dce7fd..ddde564 100644 --- a/src/eynollah/training/weights_ensembling.py +++ b/src/eynollah/training/weights_ensembling.py @@ -21,6 +21,11 @@ from tensorflow.keras.layers import * import click import logging +from transformers import TrOCRProcessor +from PIL import Image +import torch +from transformers import VisionEncoderDecoderModel + class Patches(layers.Layer): def __init__(self, patch_size_x, patch_size_y): @@ -92,30 +97,45 @@ def start_new_session(): tensorflow_backend.set_session(session) return session -def run_ensembling(dir_models, out): +def run_ensembling(dir_models, out, framework): ls_models = os.listdir(dir_models) - - - weights=[] - - for model_name in ls_models: - model = load_model(os.path.join(dir_models,model_name) , compile=False, custom_objects={'PatchEncoder':PatchEncoder, 'Patches': Patches}) - weights.append(model.get_weights()) + if framework=="torch": + models = [] + sd_models = [] - new_weights = list() - - for weights_list_tuple in zip(*weights): - new_weights.append( - [np.array(weights_).mean(axis=0)\ - for weights_ in zip(*weights_list_tuple)]) + for model_name in ls_models: + model = VisionEncoderDecoderModel.from_pretrained(os.path.join(dir_models,model_name)) + models.append(model) + sd_models.append(model.state_dict()) + for key in sd_models[0]: + sd_models[0][key] = sum(sd[key] for sd in sd_models) / len(sd_models) + + model.load_state_dict(sd_models[0]) + os.system("mkdir "+out) + torch.save(model.state_dict(), os.path.join(out, "pytorch_model.bin")) + os.system('cp '+os.path.join(os.path.join(dir_models,model_name) , "config.json ")+out) + else: + weights=[] + + for model_name in ls_models: + model = load_model(os.path.join(dir_models,model_name) , compile=False, custom_objects={'PatchEncoder':PatchEncoder, 'Patches': Patches}) + weights.append(model.get_weights()) + + new_weights = list() + + for weights_list_tuple in zip(*weights): + new_weights.append( + [np.array(weights_).mean(axis=0)\ + for weights_ in zip(*weights_list_tuple)]) + - new_weights = [np.array(x) for x in new_weights] - - model.set_weights(new_weights) - model.save(out) - os.system('cp '+os.path.join(os.path.join(dir_models,model_name) , "config.json ")+out) + new_weights = [np.array(x) for x in new_weights] + + model.set_weights(new_weights) + model.save(out) + os.system('cp '+os.path.join(os.path.join(dir_models,model_name) , "config.json ")+out) @click.command() @click.option( @@ -130,7 +150,12 @@ def run_ensembling(dir_models, out): help="output directory where ensembled model will be written.", type=click.Path(exists=False, file_okay=False), ) +@click.option( + "--framework", + "-fw", + help="this parameter gets tensorflow or torch as model framework", +) -def main(dir_models, out): - run_ensembling(dir_models, out) +def main(dir_models, out, framework): + run_ensembling(dir_models, out, framework) From a57914a68aa0c0768e62fba32e5256792ebf82cf Mon Sep 17 00:00:00 2001 From: vahidrezanezhad Date: Mon, 9 Feb 2026 18:53:08 +0100 Subject: [PATCH 05/77] fixed: textline and text correct extraction for page xml if vertical textlines are excluded + textline and text extraction for page alto files --- src/eynollah/training/extract_line_gt.py | 192 +++++++++++++++++------ 1 file changed, 145 insertions(+), 47 deletions(-) diff --git a/src/eynollah/training/extract_line_gt.py b/src/eynollah/training/extract_line_gt.py index 58fc253..6dee4b4 100644 --- a/src/eynollah/training/extract_line_gt.py +++ b/src/eynollah/training/extract_line_gt.py @@ -50,6 +50,18 @@ from ..utils import is_image_filename is_flag=True, help="if this parameter set to true, cropped textline images will not be masked with textline contour.", ) +@click.option( + "--exclude_vertical_lines", + "-exv", + is_flag=True, + help="if this parameter set to true, vertical textline images will be excluded.", +) +@click.option( + "--page_alto", + "-alto", + is_flag=True, + help="If this parameter is set to True, text line image cropping and text extraction are performed using PAGE/ALTO files. Otherwise, the default method for PAGE XML files is used.", +) def linegt_cli( image, dir_in, @@ -57,6 +69,8 @@ def linegt_cli( dir_out, pref_of_dataset, do_not_mask_with_textline_contour, + exclude_vertical_lines, + page_alto, ): assert bool(dir_in) ^ bool(image), "Set --dir-in or --image-filename, not both" if dir_in: @@ -70,65 +84,149 @@ def linegt_cli( for dir_img in ls_imgs: file_name = Path(dir_img).stem dir_xml = os.path.join(dir_xmls, file_name + '.xml') - img = cv2.imread(dir_img) + + if page_alto: + h, w = img.shape[:2] + + tree = ET.parse(dir_xml) + root = tree.getroot() - total_bb_coordinates = [] + NS = {"alto": "http://www.loc.gov/standards/alto/ns-v4#"} - tree1 = ET.parse(dir_xml, parser=ET.XMLParser(encoding="utf-8")) - root1 = tree1.getroot() - alltags = [elem.tag for elem in root1.iter()] + results = [] + + indexer_textlines = 0 + for line in root.findall(".//alto:TextLine", NS): + string_el = line.find("alto:String", NS) + textline_text = string_el.attrib["CONTENT"] if string_el is not None else None - name_space = alltags[0].split('}')[0] - name_space = name_space.split('{')[1] + polygon_el = line.find("alto:Shape/alto:Polygon", NS) + if polygon_el is None: + continue - region_tags = np.unique([x for x in alltags if x.endswith('TextRegion')]) + points = polygon_el.attrib["POINTS"].split() + coords = [ + (int(points[i]), int(points[i + 1])) + for i in range(0, len(points), 2) + ] + + coords = np.array(coords, dtype=np.int32) + x, y, w, h = cv2.boundingRect(coords) + + + if exclude_vertical_lines and h > 2 * w: + img_crop = None + continue + + img_poly_on_img = np.copy(img) - cropped_lines_region_indexer = [] + mask_poly = np.zeros(img.shape) + mask_poly = cv2.fillPoly(mask_poly, pts=[coords], color=(1, 1, 1)) - indexer_text_region = 0 - indexer_textlines = 0 - # FIXME: non recursive, use OCR-D PAGE generateDS API. Or use an existing tool for this purpose altogether - for nn in root1.iter(region_tags): - for child_textregion in nn: - if child_textregion.tag.endswith("TextLine"): - for child_textlines in child_textregion: - if child_textlines.tag.endswith("Coords"): - cropped_lines_region_indexer.append(indexer_text_region) - p_h = child_textlines.attrib['points'].split(' ') - textline_coords = np.array([[int(x.split(',')[0]), int(x.split(',')[1])] for x in p_h]) + mask_poly = mask_poly[y : y + h, x : x + w, :] + img_crop = img_poly_on_img[y : y + h, x : x + w, :] - x, y, w, h = cv2.boundingRect(textline_coords) + if not do_not_mask_with_textline_contour: + img_crop[mask_poly == 0] = 255 - total_bb_coordinates.append([x, y, w, h]) + if img_crop.shape[0] == 0 or img_crop.shape[1] == 0: + img_crop = None + continue + + if textline_text and img_crop is not None: + base_name = os.path.join( + dir_out, file_name + '_line_' + str(indexer_textlines) + ) + if pref_of_dataset: + base_name += '_' + pref_of_dataset + if not do_not_mask_with_textline_contour: + base_name += '_masked' - img_poly_on_img = np.copy(img) + with open(base_name + '.txt', 'w') as text_file: + text_file.write(textline_text) + cv2.imwrite(base_name + '.png', img_crop) + indexer_textlines += 1 + + - mask_poly = np.zeros(img.shape) - mask_poly = cv2.fillPoly(mask_poly, pts=[textline_coords], color=(1, 1, 1)) - mask_poly = mask_poly[y : y + h, x : x + w, :] - img_crop = img_poly_on_img[y : y + h, x : x + w, :] - if not do_not_mask_with_textline_contour: - img_crop[mask_poly == 0] = 255 - if img_crop.shape[0] == 0 or img_crop.shape[1] == 0: - continue - if child_textlines.tag.endswith("TextEquiv"): - for cheild_text in child_textlines: - if cheild_text.tag.endswith("Unicode"): - textline_text = cheild_text.text - if textline_text: - base_name = os.path.join( - dir_out, file_name + '_line_' + str(indexer_textlines) - ) - if pref_of_dataset: - base_name += '_' + pref_of_dataset - if not do_not_mask_with_textline_contour: - base_name += '_masked' + - with open(base_name + '.txt', 'w') as text_file: - text_file.write(textline_text) - cv2.imwrite(base_name + '.png', img_crop) - indexer_textlines += 1 + + + + + + + + + else: + total_bb_coordinates = [] + + tree = ET.parse(dir_xml, parser=ET.XMLParser(encoding="utf-8")) + root = tree.getroot() + alltags = [elem.tag for elem in root.iter()] + + name_space = alltags[0].split('}')[0] + name_space = name_space.split('{')[1] + + region_tags = np.unique([x for x in alltags if x.endswith('TextRegion')]) + + cropped_lines_region_indexer = [] + + indexer_text_region = 0 + indexer_textlines = 0 + # FIXME: non recursive, use OCR-D PAGE generateDS API. Or use an existing tool for this purpose altogether + for nn in root.iter(region_tags): + for child_textregion in nn: + if child_textregion.tag.endswith("TextLine"): + for child_textlines in child_textregion: + if child_textlines.tag.endswith("Coords"): + cropped_lines_region_indexer.append(indexer_text_region) + p_h = child_textlines.attrib['points'].split(' ') + textline_coords = np.array([[int(x.split(',')[0]), int(x.split(',')[1])] for x in p_h]) + + x, y, w, h = cv2.boundingRect(textline_coords) + + if exclude_vertical_lines and h > 2 * w: + img_crop = None + continue + + total_bb_coordinates.append([x, y, w, h]) + + img_poly_on_img = np.copy(img) + + mask_poly = np.zeros(img.shape) + mask_poly = cv2.fillPoly(mask_poly, pts=[textline_coords], color=(1, 1, 1)) + + mask_poly = mask_poly[y : y + h, x : x + w, :] + img_crop = img_poly_on_img[y : y + h, x : x + w, :] + + if not do_not_mask_with_textline_contour: + img_crop[mask_poly == 0] = 255 + + if img_crop.shape[0] == 0 or img_crop.shape[1] == 0: + img_crop = None + continue + + + if child_textlines.tag.endswith("TextEquiv"): + for cheild_text in child_textlines: + if cheild_text.tag.endswith("Unicode"): + textline_text = cheild_text.text + if textline_text and img_crop is not None: + base_name = os.path.join( + dir_out, file_name + '_line_' + str(indexer_textlines) + ) + if pref_of_dataset: + base_name += '_' + pref_of_dataset + if not do_not_mask_with_textline_contour: + base_name += '_masked' + + with open(base_name + '.txt', 'w') as text_file: + text_file.write(textline_text) + cv2.imwrite(base_name + '.png', img_crop) + indexer_textlines += 1 From ab43477451abbad0a63c838d7c9813c2e8561173 Mon Sep 17 00:00:00 2001 From: vahidrezanezhad Date: Tue, 10 Feb 2026 14:32:23 +0100 Subject: [PATCH 06/77] extracting ocr textline images and text: vertical lines threshold has changed to 1.4 --- src/eynollah/training/extract_line_gt.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/eynollah/training/extract_line_gt.py b/src/eynollah/training/extract_line_gt.py index 6dee4b4..3d508bc 100644 --- a/src/eynollah/training/extract_line_gt.py +++ b/src/eynollah/training/extract_line_gt.py @@ -115,7 +115,7 @@ def linegt_cli( x, y, w, h = cv2.boundingRect(coords) - if exclude_vertical_lines and h > 2 * w: + if exclude_vertical_lines and h > 1.4 * w: img_crop = None continue @@ -191,7 +191,7 @@ def linegt_cli( x, y, w, h = cv2.boundingRect(textline_coords) - if exclude_vertical_lines and h > 2 * w: + if exclude_vertical_lines and h > 1.4 * w: img_crop = None continue From 47fa22112c4832b60142c28036ea8a875b065209 Mon Sep 17 00:00:00 2001 From: vahidrezanezhad Date: Wed, 11 Feb 2026 19:52:56 +0100 Subject: [PATCH 07/77] import tensorflow is uncommented for ocr training --- src/eynollah/training/utils.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/eynollah/training/utils.py b/src/eynollah/training/utils.py index 9b4e01a..d44f10c 100644 --- a/src/eynollah/training/utils.py +++ b/src/eynollah/training/utils.py @@ -9,8 +9,8 @@ from scipy.ndimage.interpolation import map_coordinates from scipy.ndimage.filters import gaussian_filter from tqdm import tqdm import imutils -##import tensorflow as tf -##from tensorflow.keras.utils import to_categorical +import tensorflow as tf +from tensorflow.keras.utils import to_categorical from PIL import Image, ImageFile, ImageEnhance import torch From ed034aa8ce40a23cfbf7a3304233d62a2abb9d48 Mon Sep 17 00:00:00 2001 From: vahidrezanezhad Date: Thu, 12 Feb 2026 15:28:15 +0100 Subject: [PATCH 08/77] Update inference.py Fix broken inference model loading introduced during refactoring or merge --- src/eynollah/training/inference.py | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/src/eynollah/training/inference.py b/src/eynollah/training/inference.py index f74e9e1..d1ba4ee 100644 --- a/src/eynollah/training/inference.py +++ b/src/eynollah/training/inference.py @@ -176,15 +176,14 @@ class sbb_predict: session = tf.compat.v1.Session(config=config) # tf.InteractiveSession() tensorflow_backend.set_session(session) - - ##if self.weights_dir!=None: - ##self.model.load_weights(self.weights_dir) + self.model = load_model(self.model_dir , compile=False,custom_objects = {"PatchEncoder": PatchEncoder, "Patches": Patches}) + + if self.task != 'classification' and self.task != 'reading_order': + self.img_height=self.model.layers[len(self.model.layers)-1].output_shape[1] + self.img_width=self.model.layers[len(self.model.layers)-1].output_shape[2] + self.n_classes=self.model.layers[len(self.model.layers)-1].output_shape[3] assert isinstance(self.model, Model) - if self.task != 'classification' and self.task != 'reading_order': - self.img_height=self.model.layers[len(self.model.layers)-1].output_shape[1] - self.img_width=self.model.layers[len(self.model.layers)-1].output_shape[2] - self.n_classes=self.model.layers[len(self.model.layers)-1].output_shape[3] def visualize_model_output(self, prediction, img, task) -> Tuple[NDArray, NDArray]: if task == "binarization": From 2ee8d8e050aef1b21f896d39df19c81c190b1fd0 Mon Sep 17 00:00:00 2001 From: vahidrezanezhad Date: Thu, 12 Feb 2026 15:34:58 +0100 Subject: [PATCH 09/77] Update inference.py importing StringLookup for cnn-rnn inference --- src/eynollah/training/inference.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/eynollah/training/inference.py b/src/eynollah/training/inference.py index d1ba4ee..32bcd8a 100644 --- a/src/eynollah/training/inference.py +++ b/src/eynollah/training/inference.py @@ -12,6 +12,7 @@ from keras.models import Model, load_model from keras import backend as K import click from tensorflow.python.keras import backend as tensorflow_backend +from tensorflow.python.keras.layers import StringLookup import xml.etree.ElementTree as ET from .gt_gen_utils import ( From 68dd5eab62ee67e5c1ce8a13a7b06fdbefb14920 Mon Sep 17 00:00:00 2001 From: vahidrezanezhad Date: Thu, 12 Feb 2026 15:37:11 +0100 Subject: [PATCH 10/77] fixing: imporing StringLookup --- src/eynollah/training/inference.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/eynollah/training/inference.py b/src/eynollah/training/inference.py index 32bcd8a..c6ad186 100644 --- a/src/eynollah/training/inference.py +++ b/src/eynollah/training/inference.py @@ -12,7 +12,7 @@ from keras.models import Model, load_model from keras import backend as K import click from tensorflow.python.keras import backend as tensorflow_backend -from tensorflow.python.keras.layers import StringLookup +from tensorflow.keras.layers import StringLookup import xml.etree.ElementTree as ET from .gt_gen_utils import ( From 9aa19aa6fadbf25a35a9bb222c5c9de8d065399f Mon Sep 17 00:00:00 2001 From: vahidrezanezhad Date: Mon, 16 Feb 2026 11:45:56 +0100 Subject: [PATCH 11/77] cnn-rnn ocr inference: get input shape of model --- src/eynollah/eynollah_ocr.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/eynollah/eynollah_ocr.py b/src/eynollah/eynollah_ocr.py index 3c918e5..b5228ff 100644 --- a/src/eynollah/eynollah_ocr.py +++ b/src/eynollah/eynollah_ocr.py @@ -70,6 +70,7 @@ class Eynollah_ocr: self.model_zoo.get('ocr').to(self.device) else: self.model_zoo.load_model('ocr', '') + self.input_shape = self.model_zoo.get('ocr').input_shape[1:3] self.model_zoo.load_model('num_to_char') self.model_zoo.load_model('characters') self.end_character = len(self.model_zoo.get('characters', list)) + 2 @@ -823,8 +824,8 @@ class Eynollah_ocr: page_ns=page_ns, img_bin=img_bin, - image_width=512, - image_height=32, + image_width=self.input_shape[1], + image_height=self.input_shape[0], ) self.write_ocr( From 733462381cbe8fb60bbe953b13dec48110eb47b4 Mon Sep 17 00:00:00 2001 From: vahidrezanezhad Date: Mon, 16 Feb 2026 11:50:39 +0100 Subject: [PATCH 12/77] bug fix: layout visualization --- src/eynollah/training/generate_gt_for_training.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/eynollah/training/generate_gt_for_training.py b/src/eynollah/training/generate_gt_for_training.py index 30abd04..1c330f1 100644 --- a/src/eynollah/training/generate_gt_for_training.py +++ b/src/eynollah/training/generate_gt_for_training.py @@ -477,7 +477,7 @@ def visualize_layout_segmentation(xml_file, dir_xml, dir_out, dir_imgs): co_text, co_graphic, co_sep, co_img, co_table, co_map, co_noise, y_len, x_len = get_layout_contours_for_visualization(xml_file) - added_image = visualize_image_from_contours_layout(co_text['paragraph'], co_text['header']+co_text['heading'], co_text['drop-capital'], co_sep, co_img, co_text['marginalia'], co_table, img) + added_image = visualize_image_from_contours_layout(co_text['paragraph'], co_text['header']+co_text['heading'], co_text['drop-capital'], co_sep, co_img, co_text['marginalia'], co_table, co_map, img) cv2.imwrite(os.path.join(dir_out, f_name+'.png'), added_image) From b426f7f1525244a82ea8fdb186c7f1b7753ea3cb Mon Sep 17 00:00:00 2001 From: vahidrezanezhad Date: Wed, 18 Feb 2026 15:04:54 +0100 Subject: [PATCH 13/77] trocr inference is integrated - works on CPU cause seg fault on GPU --- src/eynollah/eynollah_ocr.py | 2 +- .../training/generate_gt_for_training.py | 9 ++-- src/eynollah/training/gt_gen_utils.py | 8 ++-- src/eynollah/training/inference.py | 46 ++++++++++++++++--- src/eynollah/utils/font.py | 4 +- 5 files changed, 52 insertions(+), 17 deletions(-) diff --git a/src/eynollah/eynollah_ocr.py b/src/eynollah/eynollah_ocr.py index b5228ff..173ba46 100644 --- a/src/eynollah/eynollah_ocr.py +++ b/src/eynollah/eynollah_ocr.py @@ -658,7 +658,7 @@ class Eynollah_ocr: if out_image_with_text: image_text = Image.new("RGB", (img.shape[1], img.shape[0]), "white") draw = ImageDraw.Draw(image_text) - font = get_font() + font = get_font(font_size=40) for indexer_text, bb_ind in enumerate(total_bb_coordinates): x_bb = bb_ind[0] diff --git a/src/eynollah/training/generate_gt_for_training.py b/src/eynollah/training/generate_gt_for_training.py index 1c330f1..28f3f1c 100644 --- a/src/eynollah/training/generate_gt_for_training.py +++ b/src/eynollah/training/generate_gt_for_training.py @@ -6,6 +6,7 @@ from pathlib import Path from PIL import Image, ImageDraw, ImageFont import cv2 import numpy as np +from eynollah.utils.font import get_font from eynollah.training.gt_gen_utils import ( filter_contours_area_of_image, @@ -514,8 +515,8 @@ def visualize_ocr_text(xml_file, dir_xml, dir_out): else: xml_files_ind = [xml_file] - font_path = "Charis-7.000/Charis-Regular.ttf" # Make sure this file exists! - font = ImageFont.truetype(font_path, 40) + ###font_path = "Charis-7.000/Charis-Regular.ttf" # Make sure this file exists! + font = get_font(font_size=40)#ImageFont.truetype(font_path, 40) for ind_xml in tqdm(xml_files_ind): indexer = 0 @@ -552,11 +553,11 @@ def visualize_ocr_text(xml_file, dir_xml, dir_out): is_vertical = h > 2*w # Check orientation - font = fit_text_single_line(draw, ocr_texts[index], font_path, w, int(h*0.4) ) + font = fit_text_single_line(draw, ocr_texts[index], w, int(h*0.4) ) if is_vertical: - vertical_font = fit_text_single_line(draw, ocr_texts[index], font_path, h, int(w * 0.8)) + vertical_font = fit_text_single_line(draw, ocr_texts[index], h, int(w * 0.8)) text_img = Image.new("RGBA", (h, w), (255, 255, 255, 0)) # Note: dimensions are swapped text_draw = ImageDraw.Draw(text_img) diff --git a/src/eynollah/training/gt_gen_utils.py b/src/eynollah/training/gt_gen_utils.py index 50bb6fa..1e15b3b 100644 --- a/src/eynollah/training/gt_gen_utils.py +++ b/src/eynollah/training/gt_gen_utils.py @@ -7,7 +7,7 @@ import cv2 from shapely import geometry from pathlib import Path from PIL import ImageFont - +from eynollah.utils.font import get_font KERNEL = np.ones((5, 5), np.uint8) @@ -350,11 +350,11 @@ def get_textline_contours_and_ocr_text(xml_file): ocr_textlines.append(ocr_text_in[0]) return co_use_case, y_len, x_len, ocr_textlines -def fit_text_single_line(draw, text, font_path, max_width, max_height): +def fit_text_single_line(draw, text, max_width, max_height): initial_font_size = 50 font_size = initial_font_size while font_size > 10: # Minimum font size - font = ImageFont.truetype(font_path, font_size) + font = get_font(font_size=font_size)# ImageFont.truetype(font_path, font_size) text_bbox = draw.textbbox((0, 0), text, font=font) # Get text bounding box text_width = text_bbox[2] - text_bbox[0] text_height = text_bbox[3] - text_bbox[1] @@ -364,7 +364,7 @@ def fit_text_single_line(draw, text, font_path, max_width, max_height): font_size -= 2 # Reduce font size and retry - return ImageFont.truetype(font_path, 10) # Smallest font fallback + return get_font(font_size=10)#ImageFont.truetype(font_path, 10) # Smallest font fallback def get_layout_contours_for_visualization(xml_file): tree1 = ET.parse(xml_file, parser = ET.XMLParser(encoding='utf-8')) diff --git a/src/eynollah/training/inference.py b/src/eynollah/training/inference.py index c6ad186..19265bc 100644 --- a/src/eynollah/training/inference.py +++ b/src/eynollah/training/inference.py @@ -170,6 +170,25 @@ class sbb_predict: self.model = tf.keras.models.Model( self.model.get_layer(name = "image").input, self.model.get_layer(name = "dense2").output) + + assert isinstance(self.model, Model) + + elif self.task == "trocr": + import torch + from transformers import VisionEncoderDecoderModel + from transformers import TrOCRProcessor + + self.model = VisionEncoderDecoderModel.from_pretrained(self.model_dir) + self.processor = TrOCRProcessor.from_pretrained(self.model_dir) + + if self.cpu: + self.device = torch.device('cpu') + else: + self.device = torch.device('cuda:0') + + self.model.to(self.device) + + assert isinstance(self.model, torch.nn.Module) else: config = tf.compat.v1.ConfigProto() config.gpu_options.allow_growth = True @@ -184,7 +203,8 @@ class sbb_predict: self.img_width=self.model.layers[len(self.model.layers)-1].output_shape[2] self.n_classes=self.model.layers[len(self.model.layers)-1].output_shape[3] - assert isinstance(self.model, Model) + + assert isinstance(self.model, Model) def visualize_model_output(self, prediction, img, task) -> Tuple[NDArray, NDArray]: if task == "binarization": @@ -235,10 +255,9 @@ class sbb_predict: return added_image, layout_only def predict(self, image_dir): - assert isinstance(self.model, Model) if self.task == 'classification': classes_names = self.config_params_model['classification_classes_name'] - img_1ch = img=cv2.imread(image_dir, 0) + img_1ch =cv2.imread(image_dir, 0) img_1ch = img_1ch / 255.0 img_1ch = cv2.resize(img_1ch, (self.config_params_model['input_height'], self.config_params_model['input_width']), interpolation=cv2.INTER_NEAREST) @@ -273,6 +292,15 @@ class sbb_predict: pred_texts = decode_batch_predictions(preds, num_to_char) pred_texts = pred_texts[0].replace("[UNK]", "") return pred_texts + + elif self.task == "trocr": + from PIL import Image + image = Image.open(image_dir).convert("RGB") + pixel_values = self.processor(image, return_tensors="pt").pixel_values + generated_ids = self.model.generate(pixel_values.to(self.device)) + return self.processor.batch_decode(generated_ids, skip_special_tokens=True)[0] + + elif self.task == 'reading_order': @@ -607,6 +635,8 @@ class sbb_predict: cv2.imwrite(self.save,res) elif self.task == "cnn-rnn-ocr": print(f"Detected text: {res}") + elif self.task == "trocr": + print(f"Detected text: {res}") else: img_seg_overlayed, only_layout = self.visualize_model_output(res, self.img_org, self.task) if self.save: @@ -710,10 +740,14 @@ class sbb_predict: ) def main(image, dir_in, model, patches, save, save_layout, ground_truth, xml_file, cpu, out, min_area): assert image or dir_in, "Either a single image -i or a dir_in -di is required" - with open(os.path.join(model,'config.json')) as f: - config_params_model = json.load(f) + try: + with open(os.path.join(model,'config_eynollah.json')) as f: + config_params_model = json.load(f) + except: + with open(os.path.join(model,'config.json')) as f: + config_params_model = json.load(f) task = config_params_model['task'] - if task != 'classification' and task != 'reading_order' and task != "cnn-rnn-ocr": + if task != 'classification' and task != 'reading_order' and task != "cnn-rnn-ocr" and task != "trocr": if image and not save: print("Error: You used one of segmentation or binarization task with image input but not set -s, you need a filename to save visualized output with -s") sys.exit(1) diff --git a/src/eynollah/utils/font.py b/src/eynollah/utils/font.py index 939933e..0354317 100644 --- a/src/eynollah/utils/font.py +++ b/src/eynollah/utils/font.py @@ -9,8 +9,8 @@ else: import importlib.resources as importlib_resources -def get_font(): +def get_font(font_size): #font_path = "Charis-7.000/Charis-Regular.ttf" # Make sure this file exists! font = importlib_resources.files(__package__) / "../Charis-Regular.ttf" with importlib_resources.as_file(font) as font: - return ImageFont.truetype(font=font, size=40) + return ImageFont.truetype(font=font, size=font_size) From 4f66734e4d0ff0882850c84fb08838828e448bb6 Mon Sep 17 00:00:00 2001 From: vahidrezanezhad Date: Wed, 18 Feb 2026 16:04:44 +0100 Subject: [PATCH 14/77] eynollah config files has renamed from config.json to config_eynollah.json - training trocr model still misses to write config file into checkpoint directories --- src/eynollah/training/inference.py | 10 ++++------ src/eynollah/training/train.py | 12 ++++++------ src/eynollah/training/weights_ensembling.py | 4 ++-- 3 files changed, 12 insertions(+), 14 deletions(-) diff --git a/src/eynollah/training/inference.py b/src/eynollah/training/inference.py index 19265bc..c613fe2 100644 --- a/src/eynollah/training/inference.py +++ b/src/eynollah/training/inference.py @@ -740,12 +740,10 @@ class sbb_predict: ) def main(image, dir_in, model, patches, save, save_layout, ground_truth, xml_file, cpu, out, min_area): assert image or dir_in, "Either a single image -i or a dir_in -di is required" - try: - with open(os.path.join(model,'config_eynollah.json')) as f: - config_params_model = json.load(f) - except: - with open(os.path.join(model,'config.json')) as f: - config_params_model = json.load(f) + + with open(os.path.join(model,'config_eynollah.json')) as f: + config_params_model = json.load(f) + task = config_params_model['task'] if task != 'classification' and task != 'reading_order' and task != "cnn-rnn-ocr" and task != "trocr": if image and not save: diff --git a/src/eynollah/training/train.py b/src/eynollah/training/train.py index 7ed8282..830fab0 100644 --- a/src/eynollah/training/train.py +++ b/src/eynollah/training/train.py @@ -68,7 +68,7 @@ class SaveWeightsAfterSteps(Callback): self.model.save(save_file) - with open(os.path.join(os.path.join(self.save_path, f"model_step_{self.step_count}"),"config.json"), "w") as fp: + with open(os.path.join(os.path.join(self.save_path, f"model_step_{self.step_count}"),"config_eynollah.json"), "w") as fp: json.dump(self._config, fp) # encode dict into JSON print(f"saved model as steps {self.step_count} to {save_file}") @@ -484,7 +484,7 @@ def run( model.save(os.path.join(dir_output,'model_'+str(i))) - with open(os.path.join(os.path.join(dir_output,'model_'+str(i)),"config.json"), "w") as fp: + with open(os.path.join(os.path.join(dir_output,'model_'+str(i)),"config_eynollah.json"), "w") as fp: json.dump(_config, fp) # encode dict into JSON #os.system('rm -rf '+dir_train_flowing) @@ -563,7 +563,7 @@ def run( if i >=0: model.save( os.path.join(dir_output,'model_'+str(i) )) - with open(os.path.join(os.path.join(dir_output,'model_'+str(i)),"config.json"), "w") as fp: + with open(os.path.join(os.path.join(dir_output,'model_'+str(i)),"config_eynollah.json"), "w") as fp: json.dump(_config, fp) # encode dict into JSON @@ -731,10 +731,10 @@ def run( model_weight_averaged.set_weights(new_weights) model_weight_averaged.save(os.path.join(dir_output,'model_ens_avg')) - with open(os.path.join( os.path.join(dir_output,'model_ens_avg'), "config.json"), "w") as fp: + with open(os.path.join( os.path.join(dir_output,'model_ens_avg'), "config_eynollah.json"), "w") as fp: json.dump(_config, fp) # encode dict into JSON - with open(os.path.join( os.path.join(dir_output,'model_best'), "config.json"), "w") as fp: + with open(os.path.join( os.path.join(dir_output,'model_best'), "config_eynollah.json"), "w") as fp: json.dump(_config, fp) # encode dict into JSON elif task=='reading_order': @@ -767,7 +767,7 @@ def run( history = model.fit(generate_arrays_from_folder_reading_order(dir_flow_train_labels, dir_flow_train_imgs, n_batch, input_height, input_width, n_classes, thetha, augmentation), steps_per_epoch=num_rows / n_batch, verbose=1) model.save( os.path.join(dir_output,'model_'+str(i+indexer_start) )) - with open(os.path.join(os.path.join(dir_output,'model_'+str(i)),"config.json"), "w") as fp: + with open(os.path.join(os.path.join(dir_output,'model_'+str(i)),"config_eynollah.json"), "w") as fp: json.dump(_config, fp) # encode dict into JSON ''' if f1score>f1score_tot[0]: diff --git a/src/eynollah/training/weights_ensembling.py b/src/eynollah/training/weights_ensembling.py index ddde564..f293658 100644 --- a/src/eynollah/training/weights_ensembling.py +++ b/src/eynollah/training/weights_ensembling.py @@ -113,7 +113,7 @@ def run_ensembling(dir_models, out, framework): model.load_state_dict(sd_models[0]) os.system("mkdir "+out) torch.save(model.state_dict(), os.path.join(out, "pytorch_model.bin")) - os.system('cp '+os.path.join(os.path.join(dir_models,model_name) , "config.json ")+out) + os.system('cp '+os.path.join(os.path.join(dir_models,model_name) , "config_eynollah.json ")+out) else: weights=[] @@ -135,7 +135,7 @@ def run_ensembling(dir_models, out, framework): model.set_weights(new_weights) model.save(out) - os.system('cp '+os.path.join(os.path.join(dir_models,model_name) , "config.json ")+out) + os.system('cp '+os.path.join(os.path.join(dir_models,model_name) , "config_eynollah.json ")+out) @click.command() @click.option( From 77adcbea8ad97d5b499ae8aeb4c53f500de02dcc Mon Sep 17 00:00:00 2001 From: vahidrezanezhad Date: Wed, 18 Feb 2026 16:47:21 +0100 Subject: [PATCH 15/77] copy characters list needed for cnn-rnn ocr model output while training and ensembling --- src/eynollah/training/train.py | 8 ++++++-- src/eynollah/training/weights_ensembling.py | 1 + 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/eynollah/training/train.py b/src/eynollah/training/train.py index 830fab0..e59ef80 100644 --- a/src/eynollah/training/train.py +++ b/src/eynollah/training/train.py @@ -52,7 +52,7 @@ from transformers import VisionEncoderDecoderModel from transformers import Seq2SeqTrainer, Seq2SeqTrainingArguments class SaveWeightsAfterSteps(Callback): - def __init__(self, save_interval, save_path, _config): + def __init__(self, save_interval, save_path, _config, characters_cnnrnn_ocr=None): super(SaveWeightsAfterSteps, self).__init__() self.save_interval = save_interval self.save_path = save_path @@ -68,6 +68,9 @@ class SaveWeightsAfterSteps(Callback): self.model.save(save_file) + if characters_cnnrnn_ocr: + os.system("cp "+characters_cnnrnn_ocr+" "+os.path.join(os.path.join(self.save_path, f"model_step_{self.step_count}"),"characters_org.txt")) + with open(os.path.join(os.path.join(self.save_path, f"model_step_{self.step_count}"),"config_eynollah.json"), "w") as fp: json.dump(self._config, fp) # encode dict into JSON print(f"saved model as steps {self.step_count} to {save_file}") @@ -544,7 +547,7 @@ def run( opt = tf.keras.optimizers.Adam(learning_rate=learning_rate)#1e-4)#(lr_schedule) model.compile(optimizer=opt) - save_weights_callback = SaveWeightsAfterSteps(save_interval, dir_output, _config) if save_interval else None + save_weights_callback = SaveWeightsAfterSteps(save_interval, dir_output, _config, characters_cnnrnn_ocr=characters_txt_file) if save_interval else None for i in tqdm(range(index_start, n_epochs + index_start)): if save_interval: @@ -563,6 +566,7 @@ def run( if i >=0: model.save( os.path.join(dir_output,'model_'+str(i) )) + os.system("cp "+characters_txt_file+" "+os.path.join(os.path.join(dir_output,'model_'+str(i)),"characters_org.txt") with open(os.path.join(os.path.join(dir_output,'model_'+str(i)),"config_eynollah.json"), "w") as fp: json.dump(_config, fp) # encode dict into JSON diff --git a/src/eynollah/training/weights_ensembling.py b/src/eynollah/training/weights_ensembling.py index f293658..2f25dbf 100644 --- a/src/eynollah/training/weights_ensembling.py +++ b/src/eynollah/training/weights_ensembling.py @@ -136,6 +136,7 @@ def run_ensembling(dir_models, out, framework): model.set_weights(new_weights) model.save(out) os.system('cp '+os.path.join(os.path.join(dir_models,model_name) , "config_eynollah.json ")+out) + os.system('cp '+os.path.join(os.path.join(dir_models,model_name) , "characters_org.txt ")+out) @click.command() @click.option( From a84ae67e7ae96a87d29fe7b62b56c17d1f951737 Mon Sep 17 00:00:00 2001 From: vahidrezanezhad Date: Thu, 19 Feb 2026 00:04:42 +0100 Subject: [PATCH 16/77] fix a typo --- src/eynollah/training/train.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/eynollah/training/train.py b/src/eynollah/training/train.py index e59ef80..11ecc8c 100644 --- a/src/eynollah/training/train.py +++ b/src/eynollah/training/train.py @@ -566,7 +566,7 @@ def run( if i >=0: model.save( os.path.join(dir_output,'model_'+str(i) )) - os.system("cp "+characters_txt_file+" "+os.path.join(os.path.join(dir_output,'model_'+str(i)),"characters_org.txt") + os.system("cp "+characters_txt_file+" "+os.path.join(os.path.join(dir_output,'model_'+str(i)),"characters_org.txt")) with open(os.path.join(os.path.join(dir_output,'model_'+str(i)),"config_eynollah.json"), "w") as fp: json.dump(_config, fp) # encode dict into JSON From c4434c7f7da47aa7d84837e5c7ca9a8928676170 Mon Sep 17 00:00:00 2001 From: vahidrezanezhad Date: Thu, 19 Feb 2026 13:59:16 +0100 Subject: [PATCH 17/77] same task name for transformer-ocr training and inference --- src/eynollah/training/inference.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/eynollah/training/inference.py b/src/eynollah/training/inference.py index c613fe2..dc7979a 100644 --- a/src/eynollah/training/inference.py +++ b/src/eynollah/training/inference.py @@ -173,7 +173,7 @@ class sbb_predict: assert isinstance(self.model, Model) - elif self.task == "trocr": + elif self.task == "transformer-ocr": import torch from transformers import VisionEncoderDecoderModel from transformers import TrOCRProcessor @@ -293,7 +293,7 @@ class sbb_predict: pred_texts = pred_texts[0].replace("[UNK]", "") return pred_texts - elif self.task == "trocr": + elif self.task == "transformer-ocr": from PIL import Image image = Image.open(image_dir).convert("RGB") pixel_values = self.processor(image, return_tensors="pt").pixel_values @@ -635,7 +635,7 @@ class sbb_predict: cv2.imwrite(self.save,res) elif self.task == "cnn-rnn-ocr": print(f"Detected text: {res}") - elif self.task == "trocr": + elif self.task == "transformer-ocr": print(f"Detected text: {res}") else: img_seg_overlayed, only_layout = self.visualize_model_output(res, self.img_org, self.task) @@ -745,7 +745,7 @@ def main(image, dir_in, model, patches, save, save_layout, ground_truth, xml_fil config_params_model = json.load(f) task = config_params_model['task'] - if task != 'classification' and task != 'reading_order' and task != "cnn-rnn-ocr" and task != "trocr": + if task != 'classification' and task != 'reading_order' and task != "cnn-rnn-ocr" and task != "transformer-ocr": if image and not save: print("Error: You used one of segmentation or binarization task with image input but not set -s, you need a filename to save visualized output with -s") sys.exit(1) From 0ca2d02ee84e66cc33e274b8317721f6decb47f6 Mon Sep 17 00:00:00 2001 From: vahidrezanezhad Date: Tue, 24 Feb 2026 01:39:12 +0100 Subject: [PATCH 18/77] get label for decoration without type attribute --- .../training/generate_gt_for_training.py | 47 +++++++++++-------- src/eynollah/training/gt_gen_utils.py | 19 +++++--- 2 files changed, 40 insertions(+), 26 deletions(-) diff --git a/src/eynollah/training/generate_gt_for_training.py b/src/eynollah/training/generate_gt_for_training.py index 28f3f1c..46b6273 100644 --- a/src/eynollah/training/generate_gt_for_training.py +++ b/src/eynollah/training/generate_gt_for_training.py @@ -356,11 +356,15 @@ def visualize_reading_order(xml_file, dir_xml, dir_out, dir_imgs): layout = np.zeros( (y_len,x_len,3) ) layout = cv2.fillPoly(layout, pts =co_text_all, color=(1,1,1)) - img_file_name_with_format = find_format_of_given_filename_in_dir(dir_imgs, f_name) - img = cv2.imread(os.path.join(dir_imgs, img_file_name_with_format)) - - overlayed = overlay_layout_on_image(layout, img, cx_ordered, cy_ordered, color, thickness) - cv2.imwrite(os.path.join(dir_out, f_name+'.png'), overlayed) + try: + img_file_name_with_format = find_format_of_given_filename_in_dir(dir_imgs, f_name) + img = cv2.imread(os.path.join(dir_imgs, img_file_name_with_format)) + + overlayed = overlay_layout_on_image(layout, img, cx_ordered, cy_ordered, color, thickness) + cv2.imwrite(os.path.join(dir_out, f_name+'.png'), overlayed) + except: + pass + else: img = np.zeros( (y_len,x_len,3) ) @@ -415,14 +419,17 @@ def visualize_textline_segmentation(xml_file, dir_xml, dir_out, dir_imgs): xml_file = os.path.join(dir_xml,ind_xml ) f_name = Path(ind_xml).stem - img_file_name_with_format = find_format_of_given_filename_in_dir(dir_imgs, f_name) - img = cv2.imread(os.path.join(dir_imgs, img_file_name_with_format)) + try: + img_file_name_with_format = find_format_of_given_filename_in_dir(dir_imgs, f_name) + img = cv2.imread(os.path.join(dir_imgs, img_file_name_with_format)) + + co_tetxlines, y_len, x_len = get_textline_contours_for_visualization(xml_file) - co_tetxlines, y_len, x_len = get_textline_contours_for_visualization(xml_file) - - added_image = visualize_image_from_contours(co_tetxlines, img) - - cv2.imwrite(os.path.join(dir_out, f_name+'.png'), added_image) + added_image = visualize_image_from_contours(co_tetxlines, img) + + cv2.imwrite(os.path.join(dir_out, f_name+'.png'), added_image) + except: + pass @@ -472,15 +479,17 @@ def visualize_layout_segmentation(xml_file, dir_xml, dir_out, dir_imgs): f_name = Path(ind_xml).stem print(f_name, 'f_name') - img_file_name_with_format = find_format_of_given_filename_in_dir(dir_imgs, f_name) - img = cv2.imread(os.path.join(dir_imgs, img_file_name_with_format)) + try: + img_file_name_with_format = find_format_of_given_filename_in_dir(dir_imgs, f_name) + img = cv2.imread(os.path.join(dir_imgs, img_file_name_with_format)) + + co_text, co_graphic, co_sep, co_img, co_table, co_map, co_noise, y_len, x_len = get_layout_contours_for_visualization(xml_file) - co_text, co_graphic, co_sep, co_img, co_table, co_map, co_noise, y_len, x_len = get_layout_contours_for_visualization(xml_file) - - - added_image = visualize_image_from_contours_layout(co_text['paragraph'], co_text['header']+co_text['heading'], co_text['drop-capital'], co_sep, co_img, co_text['marginalia'], co_table, co_map, img) + added_image = visualize_image_from_contours_layout(co_text['paragraph'], co_text['header']+co_text['heading'], co_text['drop-capital'], co_sep, co_img, co_text['marginalia'], co_table, co_map, img) - cv2.imwrite(os.path.join(dir_out, f_name+'.png'), added_image) + cv2.imwrite(os.path.join(dir_out, f_name+'.png'), added_image) + except: + pass diff --git a/src/eynollah/training/gt_gen_utils.py b/src/eynollah/training/gt_gen_utils.py index 1e15b3b..2377b7e 100644 --- a/src/eynollah/training/gt_gen_utils.py +++ b/src/eynollah/training/gt_gen_utils.py @@ -969,19 +969,21 @@ def get_images_of_ground_truth(gt_list, dir_in, output_dir, output_type, config_ if "rest_as_decoration" in types_graphic: types_graphic_without_decoration = [element for element in types_graphic if element!='rest_as_decoration' and element!='decoration'] if len(types_graphic_without_decoration) == 0: - if "type" in nn.attrib: - c_t_in_graphic['decoration'].append( np.array( [ [ int(x.split(',')[0]) , int(x.split(',')[1]) ] for x in p_h] ) ) + #if "type" in nn.attrib: + c_t_in_graphic['decoration'].append( np.array( [ [ int(x.split(',')[0]) , int(x.split(',')[1]) ] for x in p_h] ) ) elif len(types_graphic_without_decoration) >= 1: if "type" in nn.attrib: if nn.attrib['type'] in types_graphic_without_decoration: c_t_in_graphic[nn.attrib['type']].append( np.array( [ [ int(x.split(',')[0]) , int(x.split(',')[1]) ] for x in p_h] ) ) else: c_t_in_graphic['decoration'].append( np.array( [ [ int(x.split(',')[0]) , int(x.split(',')[1]) ] for x in p_h] ) ) - + else: + c_t_in_graphic['decoration'].append( np.array( [ [ int(x.split(',')[0]) , int(x.split(',')[1]) ] for x in p_h] ) ) else: if "type" in nn.attrib: if nn.attrib['type'] in all_defined_graphic_types: - c_t_in_graphic[nn.attrib['type']].append( np.array( [ [ int(x.split(',')[0]) , int(x.split(',')[1]) ] for x in p_h] ) ) + c_t_in_graphic[nn.attrib['type']].append( np.array( [ [ int(x.split(',')[0]) , int(x.split(',')[1]) ] for x in p_h] ) ) + break else: @@ -992,9 +994,9 @@ def get_images_of_ground_truth(gt_list, dir_in, output_dir, output_type, config_ if "rest_as_decoration" in types_graphic: types_graphic_without_decoration = [element for element in types_graphic if element!='rest_as_decoration' and element!='decoration'] if len(types_graphic_without_decoration) == 0: - if "type" in nn.attrib: - c_t_in_graphic['decoration'].append( [ int(float(vv.attrib['x'])) , int(float(vv.attrib['y'])) ] ) - sumi+=1 + #if "type" in nn.attrib: + c_t_in_graphic['decoration'].append( [ int(float(vv.attrib['x'])) , int(float(vv.attrib['y'])) ] ) + sumi+=1 elif len(types_graphic_without_decoration) >= 1: if "type" in nn.attrib: if nn.attrib['type'] in types_graphic_without_decoration: @@ -1003,6 +1005,9 @@ def get_images_of_ground_truth(gt_list, dir_in, output_dir, output_type, config_ else: c_t_in_graphic['decoration'].append( [ int(float(vv.attrib['x'])) , int(float(vv.attrib['y'])) ] ) sumi+=1 + else: + c_t_in_graphic['decoration'].append( [ int(float(vv.attrib['x'])) , int(float(vv.attrib['y'])) ] ) + sumi+=1 else: if "type" in nn.attrib: From 76ac4c5b711d6f2fe8291480112c63abe21363bf Mon Sep 17 00:00:00 2001 From: vahidrezanezhad Date: Tue, 24 Feb 2026 13:55:45 +0100 Subject: [PATCH 19/77] Amiri font which works for both arabic and latin --- src/eynollah/Amiri-Regular.ttf | Bin 0 -> 421196 bytes src/eynollah/utils/font.py | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) create mode 100644 src/eynollah/Amiri-Regular.ttf diff --git a/src/eynollah/Amiri-Regular.ttf b/src/eynollah/Amiri-Regular.ttf new file mode 100644 index 0000000000000000000000000000000000000000..df5e1df7305d0b9dd851d43ec1a032be34a44e67 GIT binary patch literal 421196 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 PEl3jEkR@J#B^Ve0Q;Rx4 literal 0 HcmV?d00001 diff --git a/src/eynollah/utils/font.py b/src/eynollah/utils/font.py index 0354317..3e9e588 100644 --- a/src/eynollah/utils/font.py +++ b/src/eynollah/utils/font.py @@ -11,6 +11,6 @@ else: def get_font(font_size): #font_path = "Charis-7.000/Charis-Regular.ttf" # Make sure this file exists! - font = importlib_resources.files(__package__) / "../Charis-Regular.ttf" + font = importlib_resources.files(__package__) / "../Amiri-Regular.ttf" with importlib_resources.as_file(font) as font: return ImageFont.truetype(font=font, size=font_size) From d8667f46d7251beb0caf1d1ae186ac643be7cb4c Mon Sep 17 00:00:00 2001 From: vahidrezanezhad Date: Tue, 24 Feb 2026 15:46:15 +0100 Subject: [PATCH 20/77] musicregion is added to pagexml to label --- .../training/generate_gt_for_training.py | 4 +- src/eynollah/training/gt_gen_utils.py | 72 ++++++++++++++++++- 2 files changed, 71 insertions(+), 5 deletions(-) diff --git a/src/eynollah/training/generate_gt_for_training.py b/src/eynollah/training/generate_gt_for_training.py index 46b6273..1e820f0 100644 --- a/src/eynollah/training/generate_gt_for_training.py +++ b/src/eynollah/training/generate_gt_for_training.py @@ -483,9 +483,9 @@ def visualize_layout_segmentation(xml_file, dir_xml, dir_out, dir_imgs): img_file_name_with_format = find_format_of_given_filename_in_dir(dir_imgs, f_name) img = cv2.imread(os.path.join(dir_imgs, img_file_name_with_format)) - co_text, co_graphic, co_sep, co_img, co_table, co_map, co_noise, y_len, x_len = get_layout_contours_for_visualization(xml_file) + co_text, co_graphic, co_sep, co_img, co_table, co_map, co_music, co_noise, y_len, x_len = get_layout_contours_for_visualization(xml_file) - added_image = visualize_image_from_contours_layout(co_text['paragraph'], co_text['header']+co_text['heading'], co_text['drop-capital'], co_sep, co_img, co_text['marginalia'], co_table, co_map, img) + added_image = visualize_image_from_contours_layout(co_text['paragraph'], co_text['header']+co_text['heading'], co_text['drop-capital'], co_sep, co_img, co_text['marginalia'], co_table, co_map, co_music, img) cv2.imwrite(os.path.join(dir_out, f_name+'.png'), added_image) except: diff --git a/src/eynollah/training/gt_gen_utils.py b/src/eynollah/training/gt_gen_utils.py index 2377b7e..70d48ae 100644 --- a/src/eynollah/training/gt_gen_utils.py +++ b/src/eynollah/training/gt_gen_utils.py @@ -15,7 +15,7 @@ with warnings.catch_warnings(): warnings.simplefilter("ignore") -def visualize_image_from_contours_layout(co_par, co_header, co_drop, co_sep, co_image, co_marginal, co_table, co_map, img): +def visualize_image_from_contours_layout(co_par, co_header, co_drop, co_sep, co_image, co_marginal, co_table, co_map, co_music, img): alpha = 0.5 blank_image = np.ones( (img.shape[:]), dtype=np.uint8) * 255 @@ -29,6 +29,7 @@ def visualize_image_from_contours_layout(co_par, co_header, co_drop, co_sep, co_ col_marginal = (106, 90, 205) col_table = (0, 90, 205) col_map = (90, 90, 205) + col_music = (90, 90, 0) if len(co_image)>0: cv2.drawContours(blank_image, co_image, -1, col_image, thickness=cv2.FILLED) # Fill the contour @@ -56,6 +57,9 @@ def visualize_image_from_contours_layout(co_par, co_header, co_drop, co_sep, co_ if len(co_map)>0: cv2.drawContours(blank_image, co_map, -1, col_map, thickness=cv2.FILLED) # Fill the contour + + if len(co_music)>0: + cv2.drawContours(blank_image, co_music, -1, col_music, thickness=cv2.FILLED) # Fill the contour img_final =cv2.cvtColor(blank_image, cv2.COLOR_BGR2RGB) @@ -387,6 +391,7 @@ def get_layout_contours_for_visualization(xml_file): co_img=[] co_table=[] co_map=[] + co_music=[] co_noise=[] types_text = [] @@ -628,6 +633,31 @@ def get_layout_contours_for_visualization(xml_file): elif vv.tag!=link+'Point' and sumi>=1: break co_map.append(np.array(c_t_in)) + + if tag.endswith('}MusicRegion') or tag.endswith('}musicregion'): + #print('sth') + for nn in root1.iter(tag): + c_t_in=[] + sumi=0 + for vv in nn.iter(): + # check the format of coords + if vv.tag==link+'Coords': + coords=bool(vv.attrib) + if coords: + p_h=vv.attrib['points'].split(' ') + c_t_in.append( np.array( [ [ int(x.split(',')[0]) , int(x.split(',')[1]) ] for x in p_h] ) ) + break + else: + pass + + + if vv.tag==link+'Point': + c_t_in.append([ int(float(vv.attrib['x'])) , int(float(vv.attrib['y'])) ]) + sumi+=1 + #print(vv.tag,'in') + elif vv.tag!=link+'Point' and sumi>=1: + break + co_music.append(np.array(c_t_in)) if tag.endswith('}NoiseRegion') or tag.endswith('}noiseregion'): @@ -654,7 +684,7 @@ def get_layout_contours_for_visualization(xml_file): elif vv.tag!=link+'Point' and sumi>=1: break co_noise.append(np.array(c_t_in)) - return co_text, co_graphic, co_sep, co_img, co_table, co_map, co_noise, y_len, x_len + return co_text, co_graphic, co_sep, co_img, co_table, co_map, co_music, co_noise, y_len, x_len def get_images_of_ground_truth(gt_list, dir_in, output_dir, output_type, config_file, config_params, printspace, dir_images, dir_out_images): """ @@ -873,7 +903,7 @@ def get_images_of_ground_truth(gt_list, dir_in, output_dir, output_type, config_ types_graphic_label = list(types_graphic_dict.values()) - labels_rgb_color = [ (0,0,0), (255,0,0), (255,125,0), (255,0,125), (125,255,125), (125,125,0), (0,125,255), (0,125,0), (125,125,125), (255,0,255), (125,0,125), (0,255,0),(0,0,255), (0,255,255), (255,125,125), (0,125,125), (0,255,125), (255,125,255), (125,255,0), (125,255,255)] + labels_rgb_color = [ (0,0,0), (255,0,0), (255,125,0), (255,0,125), (125,255,125), (125,125,0), (0,125,255), (0,125,0), (125,125,125), (255,0,255), (125,0,125), (0,255,0),(0,0,255), (0,255,255), (255,125,125), (0,125,125), (0,255,125), (255,125,255), (125,255,0), (125,255,255), (125,125,255)] region_tags=np.unique([x for x in alltags if x.endswith('Region')]) @@ -885,6 +915,7 @@ def get_images_of_ground_truth(gt_list, dir_in, output_dir, output_type, config_ co_img=[] co_table=[] co_map=[] + co_music=[] co_noise=[] for tag in region_tags: @@ -1126,6 +1157,32 @@ def get_images_of_ground_truth(gt_list, dir_in, output_dir, output_type, config_ elif vv.tag!=link+'Point' and sumi>=1: break co_map.append(np.array(c_t_in)) + + if 'musicregion' in keys: + if tag.endswith('}MusicRegion') or tag.endswith('}musicregion'): + #print('sth') + for nn in root1.iter(tag): + c_t_in=[] + sumi=0 + for vv in nn.iter(): + # check the format of coords + if vv.tag==link+'Coords': + coords=bool(vv.attrib) + if coords: + p_h=vv.attrib['points'].split(' ') + c_t_in.append( np.array( [ [ int(x.split(',')[0]) , int(x.split(',')[1]) ] for x in p_h] ) ) + break + else: + pass + + + if vv.tag==link+'Point': + c_t_in.append([ int(float(vv.attrib['x'])) , int(float(vv.attrib['y'])) ]) + sumi+=1 + #print(vv.tag,'in') + elif vv.tag!=link+'Point' and sumi>=1: + break + co_music.append(np.array(c_t_in)) if 'noiseregion' in keys: if tag.endswith('}NoiseRegion') or tag.endswith('}noiseregion'): @@ -1203,6 +1260,10 @@ def get_images_of_ground_truth(gt_list, dir_in, output_dir, output_type, config_ erosion_rate = 0#2 dilation_rate = 3#4 co_map, img_boundary = update_region_contours(co_map, img_boundary, erosion_rate, dilation_rate, y_len, x_len ) + if "musicregion" in elements_with_artificial_class: + erosion_rate = 0#2 + dilation_rate = 3#4 + co_music, img_boundary = update_region_contours(co_music, img_boundary, erosion_rate, dilation_rate, y_len, x_len ) @@ -1230,6 +1291,8 @@ def get_images_of_ground_truth(gt_list, dir_in, output_dir, output_type, config_ img_poly=cv2.fillPoly(img, pts =co_table, color=labels_rgb_color[ config_params['tableregion']]) if 'mapregion' in keys: img_poly=cv2.fillPoly(img, pts =co_map, color=labels_rgb_color[ config_params['mapregion']]) + if 'musicregion' in keys: + img_poly=cv2.fillPoly(img, pts =co_music, color=labels_rgb_color[ config_params['musicregion']]) if 'noiseregion' in keys: img_poly=cv2.fillPoly(img, pts =co_noise, color=labels_rgb_color[ config_params['noiseregion']]) @@ -1293,6 +1356,9 @@ def get_images_of_ground_truth(gt_list, dir_in, output_dir, output_type, config_ if 'mapregion' in keys: color_label = config_params['mapregion'] img_poly=cv2.fillPoly(img, pts =co_map, color=(color_label,color_label,color_label)) + if 'musicregion' in keys: + color_label = config_params['musicregion'] + img_poly=cv2.fillPoly(img, pts =co_music, color=(color_label,color_label,color_label)) if 'noiseregion' in keys: color_label = config_params['noiseregion'] img_poly=cv2.fillPoly(img, pts =co_noise, color=(color_label,color_label,color_label)) From fed005abd793fb730b5419a95d14263b798d970d Mon Sep 17 00:00:00 2001 From: vahidrezanezhad Date: Sat, 28 Feb 2026 09:01:06 +0100 Subject: [PATCH 21/77] Fix a bug by saving model weights in steps --- src/eynollah/training/train.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/eynollah/training/train.py b/src/eynollah/training/train.py index 11ecc8c..f75be96 100644 --- a/src/eynollah/training/train.py +++ b/src/eynollah/training/train.py @@ -58,6 +58,7 @@ class SaveWeightsAfterSteps(Callback): self.save_path = save_path self.step_count = 0 self._config = _config + self.characters_cnnrnn_ocr = characters_cnnrnn_ocr def on_train_batch_end(self, batch, logs=None): self.step_count += 1 @@ -68,8 +69,8 @@ class SaveWeightsAfterSteps(Callback): self.model.save(save_file) - if characters_cnnrnn_ocr: - os.system("cp "+characters_cnnrnn_ocr+" "+os.path.join(os.path.join(self.save_path, f"model_step_{self.step_count}"),"characters_org.txt")) + if self.characters_cnnrnn_ocr: + os.system("cp "+self.characters_cnnrnn_ocr+" "+os.path.join(os.path.join(self.save_path, f"model_step_{self.step_count}"),"characters_org.txt")) with open(os.path.join(os.path.join(self.save_path, f"model_step_{self.step_count}"),"config_eynollah.json"), "w") as fp: json.dump(self._config, fp) # encode dict into JSON From 7f7bdab208b0c2fc37055edb41e680718dd5b603 Mon Sep 17 00:00:00 2001 From: vahidrezanezhad Date: Sun, 1 Mar 2026 18:26:29 +0100 Subject: [PATCH 22/77] patches class for VIT encoder is corrected --- src/eynollah/patch_encoder.py | 66 ++++++++++++++++------------------- 1 file changed, 31 insertions(+), 35 deletions(-) diff --git a/src/eynollah/patch_encoder.py b/src/eynollah/patch_encoder.py index 939ad7b..257a5d4 100644 --- a/src/eynollah/patch_encoder.py +++ b/src/eynollah/patch_encoder.py @@ -1,52 +1,48 @@ from keras import layers import tensorflow as tf -projection_dim = 64 -patch_size = 1 -num_patches =21*21#14*14#28*28#14*14#28*28 + +class Patches(layers.Layer): + def __init__(self, patch_size_x, patch_size_y):#__init__(self, **kwargs):#:__init__(self, patch_size):#__init__(self, **kwargs): + super(Patches, self).__init__() + self.patch_size_x = patch_size_x + self.patch_size_y = patch_size_y + + def call(self, images): + #print(tf.shape(images)[1],'images') + #print(self.patch_size,'self.patch_size') + batch_size = tf.shape(images)[0] + patches = tf.image.extract_patches( + images=images, + sizes=[1, self.patch_size_y, self.patch_size_x, 1], + strides=[1, self.patch_size_y, self.patch_size_x, 1], + rates=[1, 1, 1, 1], + padding="VALID", + ) + #patch_dims = patches.shape[-1] + patch_dims = tf.shape(patches)[-1] + patches = tf.reshape(patches, [batch_size, -1, patch_dims]) + return patches class PatchEncoder(layers.Layer): - - def __init__(self): - super().__init__() + def __init__(self, num_patches, projection_dim): + super(PatchEncoder, self).__init__() + self.num_patches = num_patches self.projection = layers.Dense(units=projection_dim) - self.position_embedding = layers.Embedding(input_dim=num_patches, output_dim=projection_dim) + self.position_embedding = layers.Embedding( + input_dim=num_patches, output_dim=projection_dim + ) def call(self, patch): - positions = tf.range(start=0, limit=num_patches, delta=1) + positions = tf.range(start=0, limit=self.num_patches, delta=1) encoded = self.projection(patch) + self.position_embedding(positions) return encoded - def get_config(self): + config = super().get_config().copy() config.update({ - 'num_patches': num_patches, + 'num_patches': self.num_patches, 'projection': self.projection, 'position_embedding': self.position_embedding, }) return config - -class Patches(layers.Layer): - def __init__(self, **kwargs): - super(Patches, self).__init__() - self.patch_size = patch_size - - def call(self, images): - batch_size = tf.shape(images)[0] - patches = tf.image.extract_patches( - images=images, - sizes=[1, self.patch_size, self.patch_size, 1], - strides=[1, self.patch_size, self.patch_size, 1], - rates=[1, 1, 1, 1], - padding="VALID", - ) - patch_dims = patches.shape[-1] - patches = tf.reshape(patches, [batch_size, -1, patch_dims]) - return patches - def get_config(self): - - config = super().get_config().copy() - config.update({ - 'patch_size': self.patch_size, - }) - return config From ae3b6916ee2d49d36514ae5bc308886fb47d0d51 Mon Sep 17 00:00:00 2001 From: vahidrezanezhad Date: Sun, 1 Mar 2026 18:39:30 +0100 Subject: [PATCH 23/77] assert within vit_resnet50_unet model is commented out since arising assert error --- src/eynollah/training/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/eynollah/training/models.py b/src/eynollah/training/models.py index a1d52ce..201dc4c 100644 --- a/src/eynollah/training/models.py +++ b/src/eynollah/training/models.py @@ -424,7 +424,7 @@ def vit_resnet50_unet(n_classes, patch_size_x, patch_size_y, num_patches, mlp_he # Skip connection 2. encoded_patches = Add()([x3, x2]) - assert isinstance(x, Layer) + #assert isinstance(x, Layer) encoded_patches = tf.reshape(encoded_patches, [-1, x.shape[1], x.shape[2] , int( projection_dim / (patch_size_x * patch_size_y) )]) v1024_2048 = Conv2D( 1024 , (1, 1), padding='same', data_format=IMAGE_ORDERING,kernel_regularizer=l2(weight_decay))(encoded_patches) From 4b80e45d91532f91579fbce3d7863b90532f6a6f Mon Sep 17 00:00:00 2001 From: vahidrezanezhad Date: Tue, 3 Mar 2026 13:20:22 +0100 Subject: [PATCH 24/77] character list only needs be copied for cnn-rnn ocr model --- src/eynollah/training/weights_ensembling.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/eynollah/training/weights_ensembling.py b/src/eynollah/training/weights_ensembling.py index 2f25dbf..11a9019 100644 --- a/src/eynollah/training/weights_ensembling.py +++ b/src/eynollah/training/weights_ensembling.py @@ -136,7 +136,10 @@ def run_ensembling(dir_models, out, framework): model.set_weights(new_weights) model.save(out) os.system('cp '+os.path.join(os.path.join(dir_models,model_name) , "config_eynollah.json ")+out) - os.system('cp '+os.path.join(os.path.join(dir_models,model_name) , "characters_org.txt ")+out) + try: + os.system('cp '+os.path.join(os.path.join(dir_models,model_name) , "characters_org.txt ")+out) + except: + pass @click.command() @click.option( From f1d8257496307aa4a7428596408dad2780303b68 Mon Sep 17 00:00:00 2001 From: vahidrezanezhad Date: Tue, 3 Mar 2026 21:12:20 +0100 Subject: [PATCH 25/77] page alto label generation activated for textline --- src/eynollah/training/extract_line_gt.py | 2 +- .../training/generate_gt_for_training.py | 10 +- src/eynollah/training/gt_gen_utils.py | 370 ++++++++++-------- 3 files changed, 211 insertions(+), 171 deletions(-) diff --git a/src/eynollah/training/extract_line_gt.py b/src/eynollah/training/extract_line_gt.py index 3d508bc..819bac1 100644 --- a/src/eynollah/training/extract_line_gt.py +++ b/src/eynollah/training/extract_line_gt.py @@ -92,7 +92,7 @@ def linegt_cli( tree = ET.parse(dir_xml) root = tree.getroot() - NS = {"alto": "http://www.loc.gov/standards/alto/ns-v4#"} + NS = {'alto': root.tag.split('}')[0].strip('{')}#{"alto": "http://www.loc.gov/standards/alto/ns-v4#"} results = [] diff --git a/src/eynollah/training/generate_gt_for_training.py b/src/eynollah/training/generate_gt_for_training.py index 1e820f0..899675e 100644 --- a/src/eynollah/training/generate_gt_for_training.py +++ b/src/eynollah/training/generate_gt_for_training.py @@ -73,8 +73,14 @@ def main(): is_flag=True, help="if this parameter set to true, generated labels and in the case of provided org images cropping will be imposed and cropped labels and images will be written in output directories.", ) +@click.option( + "--page_alto", + "-alto", + is_flag=True, + help="If this parameter is set to True, textline label generation is performed using PAGE/ALTO files. Otherwise, the default method for PAGE XML files is used.", +) -def pagexml2label(dir_xml,dir_out,type_output,config, printspace, dir_images, dir_out_images): +def pagexml2label(dir_xml,dir_out,type_output,config, printspace, dir_images, dir_out_images, page_alto): if config: with open(config) as f: config_params = json.load(f) @@ -82,7 +88,7 @@ def pagexml2label(dir_xml,dir_out,type_output,config, printspace, dir_images, di print("passed") config_params = None gt_list = get_content_of_dir(dir_xml) - get_images_of_ground_truth(gt_list,dir_xml,dir_out,type_output, config, config_params, printspace, dir_images, dir_out_images) + get_images_of_ground_truth(gt_list,dir_xml,dir_out,type_output, config, config_params, printspace, dir_images, dir_out_images, page_alto) @main.command() @click.option( diff --git a/src/eynollah/training/gt_gen_utils.py b/src/eynollah/training/gt_gen_utils.py index 70d48ae..717865f 100644 --- a/src/eynollah/training/gt_gen_utils.py +++ b/src/eynollah/training/gt_gen_utils.py @@ -686,7 +686,7 @@ def get_layout_contours_for_visualization(xml_file): co_noise.append(np.array(c_t_in)) return co_text, co_graphic, co_sep, co_img, co_table, co_map, co_music, co_noise, y_len, x_len -def get_images_of_ground_truth(gt_list, dir_in, output_dir, output_type, config_file, config_params, printspace, dir_images, dir_out_images): +def get_images_of_ground_truth(gt_list, dir_in, output_dir, output_type, config_file, config_params, printspace, dir_images, dir_out_images, page_alto=False): """ Reading the page xml files and write the ground truth images into given output directory. """ @@ -696,190 +696,224 @@ def get_images_of_ground_truth(gt_list, dir_in, output_dir, output_type, config_ ls_org_imgs = os.listdir(dir_images) ls_org_imgs_stem = [os.path.splitext(item)[0] for item in ls_org_imgs] for index in tqdm(range(len(gt_list))): - #try: print(gt_list[index]) - tree1 = ET.parse(dir_in+'/'+gt_list[index], parser = ET.XMLParser(encoding='utf-8')) - root1=tree1.getroot() - alltags=[elem.tag for elem in root1.iter()] - link=alltags[0].split('}')[0]+'}' - - x_len, y_len = 0, 0 - for jj in root1.iter(link+'Page'): - y_len=int(jj.attrib['imageHeight']) - x_len=int(jj.attrib['imageWidth']) - - if 'columns_width' in list(config_params.keys()): - columns_width_dict = config_params['columns_width'] - metadata_element = root1.find(link+'Metadata') - num_col = None - for child in metadata_element: - tag2 = child.tag - if tag2.endswith('}Comments') or tag2.endswith('}comments'): - text_comments = child.text - num_col = int(text_comments.split('num_col')[1]) - - if num_col: - x_new = columns_width_dict[str(num_col)] - y_new = int ( x_new * (y_len / float(x_len)) ) - - if printspace or "printspace_as_class_in_layout" in list(config_params.keys()): - region_tags = np.unique([x for x in alltags if x.endswith('PrintSpace') or x.endswith('Border')]) - co_use_case = [] + try: + if page_alto: + tree = ET.parse(dir_in+'/'+gt_list[index]) + root = tree.getroot() - for tag in region_tags: - tag_endings = ['}PrintSpace','}Border'] + NS = {'alto': root.tag.split('}')[0].strip('{')}#{"alto": "http://www.loc.gov/standards/alto/ns-v4#"} + x_len, y_len = 0, 0 + + page = root.find('.//alto:Page', NS) + + x_len = int( page.get("WIDTH") ) + y_len = int( page.get("HEIGHT") ) + + else: + tree1 = ET.parse(dir_in+'/'+gt_list[index], parser = ET.XMLParser(encoding='utf-8')) + root1=tree1.getroot() + alltags=[elem.tag for elem in root1.iter()] + link=alltags[0].split('}')[0]+'}' + + + x_len, y_len = 0, 0 + for jj in root1.iter(link+'Page'): + y_len=int(jj.attrib['imageHeight']) + x_len=int(jj.attrib['imageWidth']) - if tag.endswith(tag_endings[0]) or tag.endswith(tag_endings[1]): - for nn in root1.iter(tag): - c_t_in = [] - sumi = 0 - for vv in nn.iter(): - # check the format of coords - if vv.tag == link + 'Coords': - coords = bool(vv.attrib) - if coords: - p_h = vv.attrib['points'].split(' ') - c_t_in.append( - np.array([[int(x.split(',')[0]), int(x.split(',')[1])] for x in p_h])) - break - else: - pass - - if vv.tag == link + 'Point': - c_t_in.append([int(float(vv.attrib['x'])), int(float(vv.attrib['y']))]) - sumi += 1 - elif vv.tag != link + 'Point' and sumi >= 1: - break - co_use_case.append(np.array(c_t_in)) + if 'columns_width' in list(config_params.keys()): + columns_width_dict = config_params['columns_width'] + metadata_element = root1.find(link+'Metadata') + num_col = None + for child in metadata_element: + tag2 = child.tag + if tag2.endswith('}Comments') or tag2.endswith('}comments'): + text_comments = child.text + num_col = int(text_comments.split('num_col')[1]) - img = np.zeros((y_len, x_len, 3)) - - img_poly = cv2.fillPoly(img, pts=co_use_case, color=(1, 1, 1)) - - img_poly = img_poly.astype(np.uint8) - - imgray = cv2.cvtColor(img_poly, cv2.COLOR_BGR2GRAY) - _, thresh = cv2.threshold(imgray, 0, 255, 0) - - contours, _ = cv2.findContours(thresh, cv2.RETR_TREE, cv2.CHAIN_APPROX_SIMPLE) - - cnt_size = np.array([cv2.contourArea(contours[j]) for j in range(len(contours))]) - - try: - cnt = contours[np.argmax(cnt_size)] - x, y, w, h = cv2.boundingRect(cnt) - except: - x, y , w, h = 0, 0, x_len, y_len - - bb_xywh = [x, y, w, h] - - - if config_file and (config_params['use_case']=='textline' or config_params['use_case']=='word' or config_params['use_case']=='glyph' or config_params['use_case']=='printspace'): - keys = list(config_params.keys()) - if "artificial_class_label" in keys: - artificial_class_rgb_color = (255,255,0) - artificial_class_label = config_params['artificial_class_label'] - - textline_rgb_color = (255, 0, 0) - - if config_params['use_case']=='textline': - region_tags = np.unique([x for x in alltags if x.endswith('TextLine')]) - elif config_params['use_case']=='word': - region_tags = np.unique([x for x in alltags if x.endswith('Word')]) - elif config_params['use_case']=='glyph': - region_tags = np.unique([x for x in alltags if x.endswith('Glyph')]) - elif config_params['use_case']=='printspace': - region_tags = np.unique([x for x in alltags if x.endswith('PrintSpace')]) - - co_use_case = [] - - for tag in region_tags: - if config_params['use_case']=='textline': - tag_endings = ['}TextLine','}textline'] - elif config_params['use_case']=='word': - tag_endings = ['}Word','}word'] - elif config_params['use_case']=='glyph': - tag_endings = ['}Glyph','}glyph'] - elif config_params['use_case']=='printspace': - tag_endings = ['}PrintSpace','}printspace'] + if num_col: + x_new = columns_width_dict[str(num_col)] + y_new = int ( x_new * (y_len / float(x_len)) ) - if tag.endswith(tag_endings[0]) or tag.endswith(tag_endings[1]): - for nn in root1.iter(tag): - c_t_in = [] - sumi = 0 - for vv in nn.iter(): - # check the format of coords - if vv.tag == link + 'Coords': - coords = bool(vv.attrib) - if coords: - p_h = vv.attrib['points'].split(' ') - c_t_in.append( - np.array([[int(x.split(',')[0]), int(x.split(',')[1])] for x in p_h])) - break - else: - pass + if printspace or "printspace_as_class_in_layout" in list(config_params.keys()): + region_tags = np.unique([x for x in alltags if x.endswith('PrintSpace') or x.endswith('Border')]) + co_use_case = [] - if vv.tag == link + 'Point': - c_t_in.append([int(float(vv.attrib['x'])), int(float(vv.attrib['y']))]) - sumi += 1 - elif vv.tag != link + 'Point' and sumi >= 1: - break - co_use_case.append(np.array(c_t_in)) - - - if "artificial_class_label" in keys: - img_boundary = np.zeros((y_len, x_len)) - erosion_rate = 0#1 - dilation_rate = 2 - dilation_early = 0 - erosion_early = 2 - co_use_case, img_boundary = update_region_contours(co_use_case, img_boundary, erosion_rate, dilation_rate, y_len, x_len, dilation_early=dilation_early, erosion_early=erosion_early) - + for tag in region_tags: + tag_endings = ['}PrintSpace','}Border'] + + if tag.endswith(tag_endings[0]) or tag.endswith(tag_endings[1]): + for nn in root1.iter(tag): + c_t_in = [] + sumi = 0 + for vv in nn.iter(): + # check the format of coords + if vv.tag == link + 'Coords': + coords = bool(vv.attrib) + if coords: + p_h = vv.attrib['points'].split(' ') + c_t_in.append( + np.array([[int(x.split(',')[0]), int(x.split(',')[1])] for x in p_h])) + break + else: + pass + + if vv.tag == link + 'Point': + c_t_in.append([int(float(vv.attrib['x'])), int(float(vv.attrib['y']))]) + sumi += 1 + elif vv.tag != link + 'Point' and sumi >= 1: + break + co_use_case.append(np.array(c_t_in)) + + img = np.zeros((y_len, x_len, 3)) + + img_poly = cv2.fillPoly(img, pts=co_use_case, color=(1, 1, 1)) + + img_poly = img_poly.astype(np.uint8) + + imgray = cv2.cvtColor(img_poly, cv2.COLOR_BGR2GRAY) + _, thresh = cv2.threshold(imgray, 0, 255, 0) + + contours, _ = cv2.findContours(thresh, cv2.RETR_TREE, cv2.CHAIN_APPROX_SIMPLE) + + cnt_size = np.array([cv2.contourArea(contours[j]) for j in range(len(contours))]) + + try: + cnt = contours[np.argmax(cnt_size)] + x, y, w, h = cv2.boundingRect(cnt) + except: + x, y , w, h = 0, 0, x_len, y_len + + bb_xywh = [x, y, w, h] - img = np.zeros((y_len, x_len, 3)) - if output_type == '2d': - img_poly = cv2.fillPoly(img, pts=co_use_case, color=(1, 1, 1)) + + if config_file and (config_params['use_case']=='textline' or config_params['use_case']=='word' or config_params['use_case']=='glyph' or config_params['use_case']=='printspace'): + keys = list(config_params.keys()) if "artificial_class_label" in keys: - img_mask = np.copy(img_poly) - ##img_poly[:,:][(img_boundary[:,:]==1) & (img_mask[:,:,0]!=1)] = artificial_class_label - img_poly[:,:][img_boundary[:,:]==1] = artificial_class_label - elif output_type == '3d': - img_poly = cv2.fillPoly(img, pts=co_use_case, color=textline_rgb_color) - if "artificial_class_label" in keys: - img_mask = np.copy(img_poly) - img_poly[:,:,0][(img_boundary[:,:]==1) & (img_mask[:,:,0]!=255)] = artificial_class_rgb_color[0] - img_poly[:,:,1][(img_boundary[:,:]==1) & (img_mask[:,:,0]!=255)] = artificial_class_rgb_color[1] - img_poly[:,:,2][(img_boundary[:,:]==1) & (img_mask[:,:,0]!=255)] = artificial_class_rgb_color[2] + artificial_class_rgb_color = (255,255,0) + artificial_class_label = config_params['artificial_class_label'] + textline_rgb_color = (255, 0, 0) - if printspace and config_params['use_case']!='printspace': - img_poly = img_poly[bb_xywh[1]:bb_xywh[1]+bb_xywh[3], bb_xywh[0]:bb_xywh[0]+bb_xywh[2], :] - - - if 'columns_width' in list(config_params.keys()) and num_col and config_params['use_case']!='printspace': - img_poly = resize_image(img_poly, y_new, x_new) + if page_alto: + co_use_case = [] + for line in root.findall(".//alto:TextLine", NS): + string_el = line.find("alto:String", NS) + textline_text = string_el.attrib["CONTENT"] if string_el is not None else None - try: - xml_file_stem = os.path.splitext(gt_list[index])[0] - cv2.imwrite(os.path.join(output_dir, xml_file_stem + '.png'), img_poly) - except: - xml_file_stem = os.path.splitext(gt_list[index])[0] - cv2.imwrite(os.path.join(output_dir, xml_file_stem + '.png'), img_poly) - - if dir_images: - org_image_name = ls_org_imgs[ls_org_imgs_stem.index(xml_file_stem)] - img_org = cv2.imread(os.path.join(dir_images, org_image_name)) + polygon_el = line.find("alto:Shape/alto:Polygon", NS) + if polygon_el is None: + continue + + points = polygon_el.attrib["POINTS"].split() + coords = [ + (int(points[i]), int(points[i + 1])) + for i in range(0, len(points), 2) + ] + + co_use_case.append( np.array(coords, dtype=np.int32) ) + else: + if config_params['use_case']=='textline': + region_tags = np.unique([x for x in alltags if x.endswith('TextLine')]) + elif config_params['use_case']=='word': + region_tags = np.unique([x for x in alltags if x.endswith('Word')]) + elif config_params['use_case']=='glyph': + region_tags = np.unique([x for x in alltags if x.endswith('Glyph')]) + elif config_params['use_case']=='printspace': + region_tags = np.unique([x for x in alltags if x.endswith('PrintSpace')]) + + co_use_case = [] + + for tag in region_tags: + if config_params['use_case']=='textline': + tag_endings = ['}TextLine','}textline'] + elif config_params['use_case']=='word': + tag_endings = ['}Word','}word'] + elif config_params['use_case']=='glyph': + tag_endings = ['}Glyph','}glyph'] + elif config_params['use_case']=='printspace': + tag_endings = ['}PrintSpace','}printspace'] + + if tag.endswith(tag_endings[0]) or tag.endswith(tag_endings[1]): + for nn in root1.iter(tag): + c_t_in = [] + sumi = 0 + for vv in nn.iter(): + # check the format of coords + if vv.tag == link + 'Coords': + coords = bool(vv.attrib) + if coords: + p_h = vv.attrib['points'].split(' ') + c_t_in.append( + np.array([[int(x.split(',')[0]), int(x.split(',')[1])] for x in p_h])) + break + else: + pass + + if vv.tag == link + 'Point': + c_t_in.append([int(float(vv.attrib['x'])), int(float(vv.attrib['y']))]) + sumi += 1 + elif vv.tag != link + 'Point' and sumi >= 1: + break + co_use_case.append(np.array(c_t_in)) + + + if "artificial_class_label" in keys: + img_boundary = np.zeros((y_len, x_len)) + erosion_rate = 0#1 + dilation_rate = 2 + dilation_early = 0 + erosion_early = 2 + co_use_case, img_boundary = update_region_contours(co_use_case, img_boundary, erosion_rate, dilation_rate, y_len, x_len, dilation_early=dilation_early, erosion_early=erosion_early) + + img = np.zeros((y_len, x_len, 3)) + if output_type == '2d': + img_poly = cv2.fillPoly(img, pts=co_use_case, color=(1, 1, 1)) + if "artificial_class_label" in keys: + img_mask = np.copy(img_poly) + ##img_poly[:,:][(img_boundary[:,:]==1) & (img_mask[:,:,0]!=1)] = artificial_class_label + img_poly[:,:][img_boundary[:,:]==1] = artificial_class_label + elif output_type == '3d': + img_poly = cv2.fillPoly(img, pts=co_use_case, color=textline_rgb_color) + if "artificial_class_label" in keys: + img_mask = np.copy(img_poly) + img_poly[:,:,0][(img_boundary[:,:]==1) & (img_mask[:,:,0]!=255)] = artificial_class_rgb_color[0] + img_poly[:,:,1][(img_boundary[:,:]==1) & (img_mask[:,:,0]!=255)] = artificial_class_rgb_color[1] + img_poly[:,:,2][(img_boundary[:,:]==1) & (img_mask[:,:,0]!=255)] = artificial_class_rgb_color[2] + + if printspace and config_params['use_case']!='printspace': - img_org = img_org[bb_xywh[1]:bb_xywh[1]+bb_xywh[3], bb_xywh[0]:bb_xywh[0]+bb_xywh[2], :] + img_poly = img_poly[bb_xywh[1]:bb_xywh[1]+bb_xywh[3], bb_xywh[0]:bb_xywh[0]+bb_xywh[2], :] + if 'columns_width' in list(config_params.keys()) and num_col and config_params['use_case']!='printspace': - img_org = resize_image(img_org, y_new, x_new) - - cv2.imwrite(os.path.join(dir_out_images, org_image_name), img_org) + img_poly = resize_image(img_poly, y_new, x_new) - + try: + xml_file_stem = os.path.splitext(gt_list[index])[0] + cv2.imwrite(os.path.join(output_dir, xml_file_stem + '.png'), img_poly) + except: + xml_file_stem = os.path.splitext(gt_list[index])[0] + cv2.imwrite(os.path.join(output_dir, xml_file_stem + '.png'), img_poly) + + if dir_images: + org_image_name = ls_org_imgs[ls_org_imgs_stem.index(xml_file_stem)] + img_org = cv2.imread(os.path.join(dir_images, org_image_name)) + + if printspace and config_params['use_case']!='printspace': + img_org = img_org[bb_xywh[1]:bb_xywh[1]+bb_xywh[3], bb_xywh[0]:bb_xywh[0]+bb_xywh[2], :] + + if 'columns_width' in list(config_params.keys()) and num_col and config_params['use_case']!='printspace': + img_org = resize_image(img_org, y_new, x_new) + + cv2.imwrite(os.path.join(dir_out_images, org_image_name), img_org) + + except: + pass + if config_file and config_params['use_case']=='layout': keys = list(config_params.keys()) From 7499e3e7b827a188753c7fd5db40c03077009a9e Mon Sep 17 00:00:00 2001 From: vahidrezanezhad Date: Fri, 13 Mar 2026 17:48:27 +0100 Subject: [PATCH 26/77] textline inference thresholding was disabled during the merging step --- src/eynollah/eynollah.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/eynollah/eynollah.py b/src/eynollah/eynollah.py index 9383c5e..287251e 100644 --- a/src/eynollah/eynollah.py +++ b/src/eynollah/eynollah.py @@ -1513,11 +1513,13 @@ class Eynollah: img_h = img_org.shape[0] img_w = img_org.shape[1] img = resize_image(img_org, int(img_org.shape[0] * scaler_h), int(img_org.shape[1] * scaler_w)) - + prediction_textline = self.do_prediction(use_patches, img, self.model_zoo.get("textline"), marginal_of_patch_percent=0.15, n_batch_inference=3, + thresholding_for_artificial_class_in_light_version=True, threshold_art_class_textline=self.threshold_art_class_textline) + prediction_textline = resize_image(prediction_textline, img_h, img_w) textline_mask_tot_ea_art = (prediction_textline[:,:]==2)*1 From 8333158eccab7d6e17607d8ebee8c2454f809754 Mon Sep 17 00:00:00 2001 From: vahidrezanezhad Date: Fri, 27 Mar 2026 09:15:19 +0100 Subject: [PATCH 27/77] BUG fixing for cnn-rnn ocr model training if augmentation is false --- src/eynollah/training/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/eynollah/training/utils.py b/src/eynollah/training/utils.py index d44f10c..aef67a0 100644 --- a/src/eynollah/training/utils.py +++ b/src/eynollah/training/utils.py @@ -1570,7 +1570,7 @@ def data_gen_ocr( if to_yield: yield to_yield else: - img_out, batchcount, ret_x, ret_y, to_yield = increment_batchcount(img_out, batchcount, ret_x, ret_y) + img_out, batchcount, ret_x, ret_y, to_yield = increment_batchcount(img, batchcount, ret_x, ret_y) if to_yield: yield to_yield From 9858221724eff1d75f10a6d6e881d726f53ee615 Mon Sep 17 00:00:00 2001 From: vahidrezanezhad Date: Fri, 27 Mar 2026 09:36:55 +0100 Subject: [PATCH 28/77] comment out printing file names while training cnn-rnn ocr model --- src/eynollah/training/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/eynollah/training/utils.py b/src/eynollah/training/utils.py index aef67a0..8b2a200 100644 --- a/src/eynollah/training/utils.py +++ b/src/eynollah/training/utils.py @@ -1343,7 +1343,7 @@ def data_gen_ocr( # TODO: Why while True + yield, why not return a list? while True: for i in ls_files_images: - print(i, 'i') + #print(i, 'i') f_name = Path(i).stem#.split('.')[0] txt_inp = open(os.path.join(dir_train, "labels/"+f_name+'.txt'),'r').read().split('\n')[0] From a1449da1d189887fff1683815b753216390452c8 Mon Sep 17 00:00:00 2001 From: Robert Sachunsky Date: Tue, 19 May 2026 03:32:19 +0200 Subject: [PATCH 29/77] Revert "fix model loading in mb_ro and ocr" This reverts commit 218a95e6a0c8881ca1919b3f00d8b15475d03e1f. --- src/eynollah/eynollah_ocr.py | 12 ++++++------ src/eynollah/mb_ro_on_layout.py | 5 +++-- .../model_zoo/.nfs00000002feddea7d00000031 | Bin 20480 -> 0 bytes src/eynollah/model_zoo/model_zoo.py | 8 ++------ 4 files changed, 11 insertions(+), 14 deletions(-) delete mode 100644 src/eynollah/model_zoo/.nfs00000002feddea7d00000031 diff --git a/src/eynollah/eynollah_ocr.py b/src/eynollah/eynollah_ocr.py index 1b49077..3c918e5 100644 --- a/src/eynollah/eynollah_ocr.py +++ b/src/eynollah/eynollah_ocr.py @@ -65,14 +65,14 @@ class Eynollah_ocr: self.b_s = 2 if batch_size is None and tr_ocr else 8 if batch_size is None else batch_size if tr_ocr: - self.model_zoo.load_models('trocr_processor') - self.model_zoo.load_models(['ocr', 'tr']) + self.model_zoo.load_model('trocr_processor') + self.model_zoo.load_model('ocr', 'tr') self.model_zoo.get('ocr').to(self.device) else: - self.model_zoo.load_models('ocr') - self.model_zoo.load_models('num_to_char') - self.model_zoo.load_models('characters') - self.end_character = len(self.model_zoo.get('characters')) + 2 + self.model_zoo.load_model('ocr', '') + self.model_zoo.load_model('num_to_char') + self.model_zoo.load_model('characters') + self.end_character = len(self.model_zoo.get('characters', list)) + 2 @property def device(self): diff --git a/src/eynollah/mb_ro_on_layout.py b/src/eynollah/mb_ro_on_layout.py index b0b5910..22fe97b 100644 --- a/src/eynollah/mb_ro_on_layout.py +++ b/src/eynollah/mb_ro_on_layout.py @@ -19,6 +19,7 @@ import statistics os.environ['TF_USE_LEGACY_KERAS'] = '1' # avoid Keras 3 after TF 2.15 import tensorflow as tf +from tensorflow.keras.models import Model from .model_zoo import EynollahModelZoo from .utils.resize import resize_image @@ -49,7 +50,7 @@ class machine_based_reading_order_on_layout: except: self.logger.warning("no GPU device available") - self.model_zoo.load_models('reading_order') + self.model_zoo.load_model('reading_order') def read_xml(self, xml_file): tree1 = ET.parse(xml_file, parser = ET.XMLParser(encoding='utf-8')) @@ -675,7 +676,7 @@ class machine_based_reading_order_on_layout: tot_counter += 1 batch.append(j) if tot_counter % inference_bs == 0 or tot_counter == len(ij_list): - y_pr = self.model_zoo.get('reading_order').predict(input_1 , verbose='0') + y_pr = self.model_zoo.get('reading_order', Model).predict(input_1 , verbose='0') for jb, j in enumerate(batch): if y_pr[jb][0]>=0.5: post_list.append(j) diff --git a/src/eynollah/model_zoo/.nfs00000002feddea7d00000031 b/src/eynollah/model_zoo/.nfs00000002feddea7d00000031 deleted file mode 100644 index c7dd87d9c2ff0b51a6c1e942f4d82b5b4ac0da25..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 20480 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 diff --git a/src/eynollah/model_zoo/model_zoo.py b/src/eynollah/model_zoo/model_zoo.py index 9611388..fffd389 100644 --- a/src/eynollah/model_zoo/model_zoo.py +++ b/src/eynollah/model_zoo/model_zoo.py @@ -94,12 +94,8 @@ class EynollahModelZoo: elif model_category.endswith('_patched'): load_args[0] = model_category[:-8] load_kwargs["patched"] = True - spec = self.specs.get(model_category, load_args[1] if len(load_args) > 1 else '') - if spec.type in ['Keras'] and spec.category != 'ocr': - ret[model_category] = Predictor(self.logger, self) - ret[model_category].load_model(*load_args, **load_kwargs, device=device) - else: - ret[model_category] = self.load_model(*load_args, **load_kwargs, device=device) + ret[model_category] = Predictor(self.logger, self) + ret[model_category].load_model(*load_args, **load_kwargs, device=device) self._loaded.update(ret) return self._loaded From 7e8b9311d3eb96b320b928088c4b9a3a88e882cf Mon Sep 17 00:00:00 2001 From: Robert Sachunsky Date: Tue, 19 May 2026 03:32:37 +0200 Subject: [PATCH 30/77] Revert "test_model_zoo: fix calls" This reverts commit 5a98f55be365e740c31aabf05f339749b6e2c6fd. --- tests/test_model_zoo.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/test_model_zoo.py b/tests/test_model_zoo.py index 9d37431..2042b28 100644 --- a/tests/test_model_zoo.py +++ b/tests/test_model_zoo.py @@ -6,11 +6,11 @@ def test_trocr1( model_zoo = EynollahModelZoo(model_dir) try: from transformers import TrOCRProcessor, VisionEncoderDecoderModel - model_zoo.load_models('trocr_processor') - proc = model_zoo.get('trocr_processor') + model_zoo.load_model('trocr_processor') + proc = model_zoo.get('trocr_processor', TrOCRProcessor) assert isinstance(proc, TrOCRProcessor) - model_zoo.load_models(['ocr', 'tr']) - model = model_zoo.get('ocr') + model_zoo.load_model('ocr', 'tr') + model = model_zoo.get('ocr', VisionEncoderDecoderModel) assert isinstance(model, VisionEncoderDecoderModel) except ImportError: pass From 98e6fbbcbbe5c1ebc8f9729cbcd6c8864ad389ac Mon Sep 17 00:00:00 2001 From: Robert Sachunsky Date: Mon, 11 May 2026 11:30:39 +0200 Subject: [PATCH 31/77] mbreorder: make work again, re-use Eynollah base class --- src/eynollah/cli/cli_readingorder.py | 11 ++++++++--- src/eynollah/mb_ro_on_layout.py | 29 ++++++++++++++++------------ 2 files changed, 25 insertions(+), 15 deletions(-) diff --git a/src/eynollah/cli/cli_readingorder.py b/src/eynollah/cli/cli_readingorder.py index 0f44b7f..eed9fb9 100644 --- a/src/eynollah/cli/cli_readingorder.py +++ b/src/eynollah/cli/cli_readingorder.py @@ -20,14 +20,19 @@ import click type=click.Path(exists=True, file_okay=False), required=True, ) +@click.option( + "--device", + "-D", + help="placement of computations in predictors for each model type; if none (by default), will try to use first available GPU or fall back to CPU; set string to force using a device (e.g. 'GPU0', 'GPU1' or 'CPU'). Can also be a comma-separated list of model category to device mappings (e.g. 'col_classifier:CPU,page:GPU0,*:GPU1')", +) @click.pass_context -def readingorder_cli(ctx, input, dir_in, out): +def readingorder_cli(ctx, input, dir_in, out, device): """ Generate ReadingOrder with a ML model """ - from ..mb_ro_on_layout import machine_based_reading_order_on_layout + from ..mb_ro_on_layout import Reorder assert bool(input) != bool(dir_in), "Either -i (single input) or -di (directory) must be provided, but not both." - orderer = machine_based_reading_order_on_layout(model_zoo=ctx.obj.model_zoo) + orderer = Reorder(model_zoo=ctx.obj.model_zoo, device=device) orderer.run(xml_filename=input, dir_in=dir_in, dir_out=out, diff --git a/src/eynollah/mb_ro_on_layout.py b/src/eynollah/mb_ro_on_layout.py index 22fe97b..5725ba1 100644 --- a/src/eynollah/mb_ro_on_layout.py +++ b/src/eynollah/mb_ro_on_layout.py @@ -21,6 +21,7 @@ os.environ['TF_USE_LEGACY_KERAS'] = '1' # avoid Keras 3 after TF 2.15 import tensorflow as tf from tensorflow.keras.models import Model +from .eynollah import Eynollah from .model_zoo import EynollahModelZoo from .utils.resize import resize_image from .utils.contour import ( @@ -34,23 +35,27 @@ DPI_THRESHOLD = 298 KERNEL = np.ones((5, 5), np.uint8) -class machine_based_reading_order_on_layout: +class Reorder(Eynollah): def __init__( - self, - *, - model_zoo: EynollahModelZoo, - logger : Optional[logging.Logger] = None, + self, + *, + model_zoo: EynollahModelZoo, + logger : Optional[logging.Logger] = None, + device: str = '', ): self.logger = logger or logging.getLogger('eynollah.mbreorder') self.model_zoo = model_zoo - try: - for device in tf.config.list_physical_devices('GPU'): - tf.config.experimental.set_memory_growth(device, True) - except: - self.logger.warning("no GPU device available") - self.model_zoo.load_model('reading_order') + self.setup_models(device=device) + + def setup_models(self, device=''): + loadable = ['reading_order'] + self.model_zoo.load_models(*loadable, device=device) + for model in loadable: + self.logger.debug("model %s has input shape %s", model, + self.model_zoo.get(model).input_shape) + def read_xml(self, xml_file): tree1 = ET.parse(xml_file, parser = ET.XMLParser(encoding='utf-8')) @@ -676,7 +681,7 @@ class machine_based_reading_order_on_layout: tot_counter += 1 batch.append(j) if tot_counter % inference_bs == 0 or tot_counter == len(ij_list): - y_pr = self.model_zoo.get('reading_order', Model).predict(input_1 , verbose='0') + y_pr = self.model_zoo.get('reading_order').predict(input_1, verbose=0) for jb, j in enumerate(batch): if y_pr[jb][0]>=0.5: post_list.append(j) From ded668a2562d2dc59646554a06338303cf2a6034 Mon Sep 17 00:00:00 2001 From: Robert Sachunsky Date: Tue, 12 May 2026 18:17:43 +0200 Subject: [PATCH 32/77] =?UTF-8?q?model=5Fzoo:=20fix=20clash=20between=20Pr?= =?UTF-8?q?edictor=20and=20direct=20(OCR)=20use-cases=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `load_models()`: uniformly handle arg types - `load_model()`: move handling of non-model categories to `load_models()` - `load_model()`: move SavedModel preference over HDF5 to `model_path()` - `_load_ocr_model()`: add user-selected device handling and reporting for Torch (as for TF) - `_load_ocr_model()`: move (TF-based) CNN-RNN case to `load_model()` (including Keras layer mapping) - `shutdown()`: only apply `shutdown()` to Predictor model types --- src/eynollah/model_zoo/model_zoo.py | 144 +++++++++++++++++----------- 1 file changed, 87 insertions(+), 57 deletions(-) diff --git a/src/eynollah/model_zoo/model_zoo.py b/src/eynollah/model_zoo/model_zoo.py index fffd389..7f3cd6c 100644 --- a/src/eynollah/model_zoo/model_zoo.py +++ b/src/eynollah/model_zoo/model_zoo.py @@ -70,6 +70,9 @@ class EynollahModelZoo: model_path = Path(self.model_basedir).joinpath(spec.filename) else: model_path = Path(spec.filename) + if model_path.suffix == '.h5' and Path(model_path.stem).exists(): + # prefer SavedModel over HDF5 format if it exists + model_path = Path(model_path.stem) return model_path def load_models( @@ -82,28 +85,50 @@ class EynollahModelZoo: """ ret = {} # cannot use self._loaded here, yet – first spawn all predictors for load_args in all_load_args: + load_kwargs = dict(device=device) if isinstance(load_args, str): - model_category = load_args - load_args = [model_category] + model_category, model_variant = load_args, "" + elif len(load_args) > 2: + # for calls to self.model_path + self.override_models(load_args) + # for calls to Predictor.load_model + model_category, model_variant, model_path = load_args + load_kwargs["model_variant"] = model_variant + load_kwargs["model_path_override"] = model_path else: - model_category = load_args[0] - load_kwargs = {} + model_category, model_variant = load_args + load_kwargs["model_variant"] = model_variant + if model_category.endswith('_resized'): - load_args[0] = model_category[:-8] + model_category = model_category[:-8] load_kwargs["resized"] = True elif model_category.endswith('_patched'): - load_args[0] = model_category[:-8] + model_category = model_category[:-8] load_kwargs["patched"] = True - ret[model_category] = Predictor(self.logger, self) - ret[model_category].load_model(*load_args, **load_kwargs, device=device) + + if model_category == 'ocr': + model = self._load_ocr_model(variant=model_variant, device=device) + elif model_category == 'num_to_char': + model = self._load_num_to_char() + elif model_category == 'characters': + model = self._load_characters() + elif model_category == 'trocr_processor': + from transformers import TrOCRProcessor + model_path = self.model_path(model_category, model_variant) + model = TrOCRProcessor.from_pretrained(model_path) + else: + model = Predictor(self.logger, self) + model.load_model(model_category, **load_kwargs) + + ret[model_category] = model self._loaded.update(ret) return self._loaded def load_model( - self, - model_category: str, - model_variant: str = '', - model_path_override: Optional[str] = None, + self, + model_category: str, + model_variant: str = '', + model_path_override: Optional[str] = None, patched: bool = False, resized: bool = False, device: str = '', @@ -117,6 +142,7 @@ class EynollahModelZoo: import tensorflow as tf from tensorflow.keras.models import load_model + from tensorflow.keras.models import Model as KerasModel from ..patch_encoder import ( PatchEncoder, @@ -162,38 +188,33 @@ class EynollahModelZoo: if model_path_override: self.override_models((model_category, model_variant, model_path_override)) model_path = self.model_path(model_category, model_variant) - if model_path.suffix == '.h5' and Path(model_path.stem).exists(): - # prefer SavedModel over HDF5 format if it exists - model_path = Path(model_path.stem) - if model_category == 'ocr': - model = self._load_ocr_model(variant=model_variant) - elif model_category == 'num_to_char': - model = self._load_num_to_char() - elif model_category == 'characters': - model = self._load_characters() - elif model_category == 'trocr_processor': - from transformers import TrOCRProcessor - model = TrOCRProcessor.from_pretrained(model_path) + try: + # avoid wasting VRAM on non-transformer models + model = load_model(model_path, compile=False) + except Exception as e: + self.logger.error(e) + model = load_model( + model_path, compile=False, + custom_objects=dict(PatchEncoder=PatchEncoder, + Patches=Patches)) + assert isinstance(model, KerasModel) + model._name = model_category + if resized: + model = wrap_layout_model_resized(model) + model._name = model_category + '_resized' + elif patched: + model = wrap_layout_model_patched(model) + model._name = model_category + '_patched' else: - try: - # avoid wasting VRAM on non-transformer models - model = load_model(model_path, compile=False) - except Exception as e: - self.logger.error(e) - model = load_model( - model_path, compile=False, - custom_objects=dict(PatchEncoder=PatchEncoder, - Patches=Patches)) - model._name = model_category - if resized: - model = wrap_layout_model_resized(model) - model._name = model_category + '_resized' - elif patched: - model = wrap_layout_model_patched(model) - model._name = model_category + '_patched' - else: - model.jit_compile = True - model.make_predict_function() + model.jit_compile = True + + if model_category == 'ocr': + model = KerasModel( + model.get_layer(name="image").input, # type: ignore + model.get_layer(name="dense2").output, # type: ignore + ) + + model.make_predict_function() return model def get(self, model_category: str) -> Predictor: @@ -201,26 +222,34 @@ class EynollahModelZoo: raise ValueError(f'Model "{model_category}" not previously loaded with "load_model(..)"') return self._loaded[model_category] - def _load_ocr_model(self, variant: str) -> AnyModel: + def _load_ocr_model(self, variant: str, device: str = "") -> AnyModel: """ Load OCR model """ - from tensorflow.keras.models import Model as KerasModel - from tensorflow.keras.models import load_model - - ocr_model_dir = self.model_path('ocr', variant) + model_dir = self.model_path('ocr', variant) if variant == 'tr': from transformers import VisionEncoderDecoderModel - ret = VisionEncoderDecoderModel.from_pretrained(ocr_model_dir) + import torch + ret = VisionEncoderDecoderModel.from_pretrained(model_dir) assert isinstance(ret, VisionEncoderDecoderModel) + dev = torch.device('cpu') + if not device and torch.cuda.is_available(): + device = 'GPU' # try + if device and device.startswith('GPU'): + try: + dev = torch.device('cuda', int(device[3:] or 0)) + name = torch.cuda.get_device_name(dev) + self.logger.info("using GPU %s (%s) for model ocr:tr", dev, name) + except: + self.logger.exception("cannot configure GPU device") + dev = torch.device('cpu') + if dev.type == 'cuda': + ret.to(dev) + else: + self.logger.warning("no GPU device available") return ret - else: - ocr_model = load_model(ocr_model_dir, compile=False) - assert isinstance(ocr_model, KerasModel) - return KerasModel( - ocr_model.get_layer(name="image").input, # type: ignore - ocr_model.get_layer(name="dense2").output, # type: ignore - ) + + return self.load_model('ocr', model_variant=variant, device=device) def _load_characters(self) -> List[str]: """ @@ -273,5 +302,6 @@ class EynollahModelZoo: """ if hasattr(self, '_loaded') and getattr(self, '_loaded'): for needle in list(self._loaded.keys()): - self._loaded[needle].shutdown() + if isinstance(self._loaded[needle], Predictor): + self._loaded[needle].shutdown() del self._loaded[needle] From cd62f13872419deb3c8740e8d5ded6a21cdec3c9 Mon Sep 17 00:00:00 2001 From: Robert Sachunsky Date: Tue, 12 May 2026 18:31:18 +0200 Subject: [PATCH 33/77] =?UTF-8?q?eynollah=5Focr:=20make=20work=20again,=20?= =?UTF-8?q?re-use=20Eynollah=20base=20class=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - re-use Eynollah base class - use `ModelZoo.load_models()` instead of `load_model()` - pass in `device` init kwarg, delegate to `ModelZoo.load_models()` - `device`: return Torch device at loaded model tensors instead of ad-hoc selection - make numeric init kwargs non-optional (only numeric) --- src/eynollah/cli/cli_ocr.py | 10 ++++++-- src/eynollah/eynollah_ocr.py | 48 ++++++++++++++++++------------------ 2 files changed, 32 insertions(+), 26 deletions(-) diff --git a/src/eynollah/cli/cli_ocr.py b/src/eynollah/cli/cli_ocr.py index 406af61..f9b74c8 100644 --- a/src/eynollah/cli/cli_ocr.py +++ b/src/eynollah/cli/cli_ocr.py @@ -66,6 +66,10 @@ import click "--min_conf_value_of_textline_text", "-min_conf", help="minimum OCR confidence value. Text lines with a confidence value lower than this threshold will not be included in the output XML file.", +@click.option( + "--device", + "-D", + help="placement of computations in predictors for each model type; if none (by default), will try to use first available GPU or fall back to CPU; set string to force using a device (e.g. 'GPU0', 'GPU1' or 'CPU'). Can also be a comma-separated list of model category to device mappings (e.g. 'col_classifier:CPU,page:GPU0,*:GPU1')", ) @click.pass_context def ocr_cli( @@ -81,18 +85,20 @@ def ocr_cli( do_not_mask_with_textline_contour, batch_size, min_conf_value_of_textline_text, + device, ): """ Recognize text with a CNN/RNN or transformer ML model. """ - assert bool(image) ^ bool(dir_in), "Either -i (single image) or -di (directory) must be provided, but not both." + assert bool(image) != bool(dir_in), "Either -i (single image) or -di (directory) must be provided, but not both." from ..eynollah_ocr import Eynollah_ocr eynollah_ocr = Eynollah_ocr( model_zoo=ctx.obj.model_zoo, tr_ocr=tr_ocr, do_not_mask_with_textline_contour=do_not_mask_with_textline_contour, batch_size=batch_size, - min_conf_value_of_textline_text=min_conf_value_of_textline_text) + min_conf_value_of_textline_text=min_conf_value_of_textline_text, + device=device) eynollah_ocr.run(overwrite=overwrite, dir_in=dir_in, dir_in_bin=dir_in_bin, diff --git a/src/eynollah/eynollah_ocr.py b/src/eynollah/eynollah_ocr.py index 3c918e5..4470671 100644 --- a/src/eynollah/eynollah_ocr.py +++ b/src/eynollah/eynollah_ocr.py @@ -14,16 +14,17 @@ from cv2.typing import MatLike from xml.etree import ElementTree as ET from PIL import Image, ImageDraw import numpy as np -from eynollah.model_zoo import EynollahModelZoo -from eynollah.utils.font import get_font -from eynollah.utils.xml import etree_namespace_for_element_tag try: import torch except ImportError: torch = None +from .eynollah import Eynollah +from .model_zoo import EynollahModelZoo from .utils import is_image_filename +from .utils.font import get_font +from .utils.xml import etree_namespace_for_element_tag from .utils.resize import resize_image from .utils.utils_ocr import ( break_curved_line_into_small_pieces_and_then_merge, @@ -44,45 +45,44 @@ class EynollahOcrResult: cropped_lines_region_indexer: List total_bb_coordinates:List -class Eynollah_ocr: +class Eynollah_ocr(Eynollah): def __init__( self, *, model_zoo: EynollahModelZoo, tr_ocr=False, - batch_size: Optional[int]=None, + batch_size: int=0, do_not_mask_with_textline_contour: bool=False, - min_conf_value_of_textline_text : Optional[float]=None, + min_conf_value_of_textline_text : float=0.3, logger: Optional[Logger]=None, + device: str = '', ): self.tr_ocr = tr_ocr # masking for OCR and GT generation, relevant for skewed lines and bounding boxes self.do_not_mask_with_textline_contour = do_not_mask_with_textline_contour self.logger = logger if logger else getLogger('eynollah.ocr') - self.model_zoo = model_zoo - self.min_conf_value_of_textline_text = min_conf_value_of_textline_text if min_conf_value_of_textline_text else 0.3 - self.b_s = 2 if batch_size is None and tr_ocr else 8 if batch_size is None else batch_size + self.min_conf_value_of_textline_text = min_conf_value_of_textline_text + self.b_s = batch_size or 2 if tr_ocr else 8 - if tr_ocr: - self.model_zoo.load_model('trocr_processor') - self.model_zoo.load_model('ocr', 'tr') - self.model_zoo.get('ocr').to(self.device) + self.model_zoo = model_zoo + self.setup_models(device=device) + + def setup_models(self, device=''): + if self.tr_ocr: + self.model_zoo.load_models('trocr_processor', + ('ocr', 'tr'), + device=device) else: - self.model_zoo.load_model('ocr', '') - self.model_zoo.load_model('num_to_char') - self.model_zoo.load_model('characters') - self.end_character = len(self.model_zoo.get('characters', list)) + 2 + self.model_zoo.load_models('ocr', + 'num_to_char', + 'characters', + device=device) + self.end_character = len(self.model_zoo.get('characters')) + 2 @property def device(self): - assert torch - if torch.cuda.is_available(): - self.logger.info("Using GPU acceleration") - return torch.device("cuda:0") - else: - self.logger.info("Using CPU processing") - return torch.device("cpu") + return self.model_zoo.get('ocr').device def run_trocr( self, From 7ed1a1ebac0c4b34db02b254c3dbb5c3d639ed9c Mon Sep 17 00:00:00 2001 From: Robert Sachunsky Date: Tue, 12 May 2026 18:34:56 +0200 Subject: [PATCH 34/77] CLIs: allow `-h` and show defaults uniformly, harmonise help, drop remaining redundant negative options --- src/eynollah/cli/cli_binarize.py | 4 +++- src/eynollah/cli/cli_enhance.py | 4 +++- src/eynollah/cli/cli_extract_images.py | 32 +++++++++++++++----------- src/eynollah/cli/cli_ocr.py | 21 +++++++++++------ src/eynollah/cli/cli_readingorder.py | 4 +++- 5 files changed, 42 insertions(+), 23 deletions(-) diff --git a/src/eynollah/cli/cli_binarize.py b/src/eynollah/cli/cli_binarize.py index f0e56f5..d544a67 100644 --- a/src/eynollah/cli/cli_binarize.py +++ b/src/eynollah/cli/cli_binarize.py @@ -1,6 +1,8 @@ import click -@click.command() +@click.command(context_settings=dict( + help_option_names=['-h', '--help'], + show_default=True)) @click.option( '--patches/--no-patches', default=True, diff --git a/src/eynollah/cli/cli_enhance.py b/src/eynollah/cli/cli_enhance.py index 517e1e8..42b1d41 100644 --- a/src/eynollah/cli/cli_enhance.py +++ b/src/eynollah/cli/cli_enhance.py @@ -1,6 +1,8 @@ import click -@click.command() +@click.command(context_settings=dict( + help_option_names=['-h', '--help'], + show_default=True)) @click.option( "--image", "-i", diff --git a/src/eynollah/cli/cli_extract_images.py b/src/eynollah/cli/cli_extract_images.py index 0add5b5..acd31f1 100644 --- a/src/eynollah/cli/cli_extract_images.py +++ b/src/eynollah/cli/cli_extract_images.py @@ -1,6 +1,8 @@ import click -@click.command() +@click.command(context_settings=dict( + help_option_names=['-h', '--help'], + show_default=True)) @click.option( "--image", "-i", @@ -30,36 +32,40 @@ import click @click.option( "--save_images", "-si", - help="if a directory is given, images in documents will be cropped and saved there", + help="if a directory is given, cropped images of pages will be saved there", type=click.Path(exists=True, file_okay=False), ) @click.option( - "--enable-plotting/--disable-plotting", - "-ep/-noep", + "--enable-plotting", + "-ep", is_flag=True, - help="If set, will plot intermediary files and images", + help="plot intermediary diagnostic images to files", ) @click.option( - "--input_binary/--input-RGB", - "-ib/-irgb", + "--input_binary", + "-ib", is_flag=True, - help="In general, eynollah uses RGB as input but if the input document is very dark, very bright or for any other reason you can turn on input binarization. When this flag is set, eynollah will binarize the RGB input document, you should always provide RGB images to eynollah.", + help="In general, eynollah uses RGB as input, but if the input document is very dark, very bright or for any other reason you can turn on internal binarization here. When set, eynollah will binarize the RGB input document first.", ) @click.option( - "--ignore_page_extraction/--extract_page_included", - "-ipe/-epi", + "--ignore_page_extraction", + "-ipe", is_flag=True, - help="if this parameter set to true, this tool would ignore page extraction", + help="ignore page extraction (cropping via page frame detection model)", ) @click.option( "--num_col_upper", "-ncu", - help="lower limit of columns in document image", + default=0, + type=click.IntRange(min=0), + help="lower limit of columns in document image; 0 means autodetected from model", ) @click.option( "--num_col_lower", "-ncl", - help="upper limit of columns in document image", + default=0, + type=click.IntRange(min=0), + help="upper limit of columns in document image; 0 means autodetected from model", ) @click.pass_context def extract_images_cli( diff --git a/src/eynollah/cli/cli_ocr.py b/src/eynollah/cli/cli_ocr.py index f9b74c8..99e03c5 100644 --- a/src/eynollah/cli/cli_ocr.py +++ b/src/eynollah/cli/cli_ocr.py @@ -1,6 +1,8 @@ import click -@click.command() +@click.command(context_settings=dict( + help_option_names=['-h', '--help'], + show_default=True)) @click.option( "--image", "-i", @@ -16,7 +18,7 @@ import click @click.option( "--dir_in_bin", "-dib", - help=("directory of binarized images (in addition to --dir_in for RGB images; filename stems must match the RGB image files, with '.png' \n Perform prediction using both RGB and binary images. (This does not necessarily improve results, however it may be beneficial for certain document images."), + help=("directory of binarized images (in addition to --dir_in for RGB images; filename stems must match the RGB image files, with '.png'. \n Perform prediction using both RGB and binary images. (This may improve results for certain document images.)"), type=click.Path(exists=True, file_okay=False), ) @click.option( @@ -47,25 +49,30 @@ import click ) @click.option( "--tr_ocr", - "-trocr/-notrocr", + "-trocr", is_flag=True, - help="if this parameter set to true, transformer ocr will be applied, otherwise cnn_rnn model.", + help="use transformer OCR (instead of classic CNN-RNN) model", ) @click.option( "--do_not_mask_with_textline_contour", - "-nmtc/-mtc", + "-nmtc", is_flag=True, - help="if this parameter set to true, cropped textline images will not be masked with textline contour.", + help="skip masking each cropped textline image with its corresponding textline contour", ) @click.option( "--batch_size", "-bs", + default=0, + type=click.IntRange(min=0), help="number of inference batch size. Default b_s for trocr and cnn_rnn models are 2 and 8 respectively", ) @click.option( "--min_conf_value_of_textline_text", "-min_conf", - help="minimum OCR confidence value. Text lines with a confidence value lower than this threshold will not be included in the output XML file.", + default=0.3, + type=click.FloatRange(min=0.0, max=1.0), + help="minimum OCR confidence threshold. Text lines with a lower confidence value will not be included in the output XML file.", +) @click.option( "--device", "-D", diff --git a/src/eynollah/cli/cli_readingorder.py b/src/eynollah/cli/cli_readingorder.py index eed9fb9..9bb7092 100644 --- a/src/eynollah/cli/cli_readingorder.py +++ b/src/eynollah/cli/cli_readingorder.py @@ -1,6 +1,8 @@ import click -@click.command() +@click.command(context_settings=dict( + help_option_names=['-h', '--help'], + show_default=True)) @click.option( "--input", "-i", From 21ecb043f763045aabc043805acb4d39da0316c9 Mon Sep 17 00:00:00 2001 From: Robert Sachunsky Date: Tue, 12 May 2026 18:41:21 +0200 Subject: [PATCH 35/77] CLIs: move `--device` option to group level --- src/eynollah/cli/cli.py | 9 ++++++++- src/eynollah/cli/cli_binarize.py | 9 ++------- src/eynollah/cli/cli_enhance.py | 9 ++------- src/eynollah/cli/cli_layout.py | 8 +------- src/eynollah/cli/cli_ocr.py | 9 ++------- src/eynollah/cli/cli_readingorder.py | 10 +++------- 6 files changed, 18 insertions(+), 36 deletions(-) diff --git a/src/eynollah/cli/cli.py b/src/eynollah/cli/cli.py index ace3f1c..2a4c8d1 100644 --- a/src/eynollah/cli/cli.py +++ b/src/eynollah/cli/cli.py @@ -15,6 +15,7 @@ class EynollahCliCtx: Holds options relevant for all eynollah subcommands """ model_zoo: EynollahModelZoo + device: str = '' log_level : Union[str, None] = 'INFO' @@ -35,6 +36,11 @@ class EynollahCliCtx: type=(str, str, str), multiple=True, ) +@click.option( + "--device", + "-D", + help="placement of computations in predictors for each model type; if none (by default), will try to use first available GPU or fall back to CPU; set string to force using a device (e.g. 'GPU0', 'GPU1' or 'CPU'). Can also be a comma-separated list of model category to device mappings (e.g. 'col_classifier:CPU,page:GPU0,*:GPU1')", +) @click.option( "--log_level", "-l", @@ -42,7 +48,7 @@ class EynollahCliCtx: help="Override log level globally to this", ) @click.pass_context -def main(ctx, model_basedir, model_overrides, log_level): +def main(ctx, model_basedir, model_overrides, device, log_level): """ eynollah - Document Layout Analysis, Image Enhancement, OCR """ @@ -58,6 +64,7 @@ def main(ctx, model_basedir, model_overrides, log_level): # Initialize CLI context ctx.obj = EynollahCliCtx( model_zoo=model_zoo, + device=device, log_level=log_level, ) diff --git a/src/eynollah/cli/cli_binarize.py b/src/eynollah/cli/cli_binarize.py index d544a67..82209be 100644 --- a/src/eynollah/cli/cli_binarize.py +++ b/src/eynollah/cli/cli_binarize.py @@ -33,11 +33,6 @@ import click help="overwrite (instead of skipping) if output xml exists", is_flag=True, ) -@click.option( - "--device", - "-D", - help="placement of computations in predictors for each model type; if none (by default), will try to use first available GPU or fall back to CPU; set string to force using a device (e.g. 'GPU0', 'GPU1' or 'CPU'). Can also be a comma-separated list of model category to device mappings (e.g. 'col_classifier:CPU,page:GPU0,*:GPU1')", -) @click.pass_context def binarize_cli( ctx, @@ -46,14 +41,14 @@ def binarize_cli( dir_in, output, overwrite, - device, ): """ Binarize images with a ML model """ from ..sbb_binarize import SbbBinarizer assert bool(input_image) != bool(dir_in), "Either -i (single input) or -di (directory) must be provided, but not both." - binarizer = SbbBinarizer(model_zoo=ctx.obj.model_zoo, device=device) + binarizer = SbbBinarizer(model_zoo=ctx.obj.model_zoo, + device=ctx.obj.device) binarizer.run( image_filename=input_image, use_patches=patches, diff --git a/src/eynollah/cli/cli_enhance.py b/src/eynollah/cli/cli_enhance.py index 42b1d41..bcb8263 100644 --- a/src/eynollah/cli/cli_enhance.py +++ b/src/eynollah/cli/cli_enhance.py @@ -48,13 +48,8 @@ import click is_flag=True, help="save the enhanced image in original image size", ) -@click.option( - "--device", - "-D", - help="placement of computations in predictors for each model type; if none (by default), will try to use first available GPU or fall back to CPU; set string to force using a device (e.g. 'GPU0', 'GPU1' or 'CPU'). Can also be a comma-separated list of model category to device mappings (e.g. 'col_classifier:CPU,page:GPU0,*:GPU1')", -) @click.pass_context -def enhance_cli(ctx, image, out, overwrite, dir_in, num_col_upper, num_col_lower, save_org_scale, device): +def enhance_cli(ctx, image, out, overwrite, dir_in, num_col_upper, num_col_lower, save_org_scale): """ Enhance image """ @@ -62,10 +57,10 @@ def enhance_cli(ctx, image, out, overwrite, dir_in, num_col_upper, num_col_lower from ..image_enhancer import Enhancer enhancer = Enhancer( model_zoo=ctx.obj.model_zoo, + device=ctx.obj.device, num_col_upper=num_col_upper, num_col_lower=num_col_lower, save_org_scale=save_org_scale, - device=device, ) enhancer.run(overwrite=overwrite, dir_in=dir_in, diff --git a/src/eynollah/cli/cli_layout.py b/src/eynollah/cli/cli_layout.py index 417b202..0a083d5 100644 --- a/src/eynollah/cli/cli_layout.py +++ b/src/eynollah/cli/cli_layout.py @@ -172,11 +172,6 @@ import click type=click.FloatRange(min=0), help="abort when number of failed images exceeds this value (if >=1) or ratio of failed over total images exceeds this value (if <1); 0 means ignore failures", ) -@click.option( - "--device", - "-D", - help="placement of computations in predictors for each model type; if none (by default), will try to use first available GPU or fall back to CPU; set string to force using a device (e.g. 'GPU0', 'GPU1' or 'CPU'). Can also be a comma-separated list of model category to device mappings (e.g. 'col_classifier:CPU,page:GPU0,*:GPU1')", -) @click.pass_context def layout_cli( ctx, @@ -207,7 +202,6 @@ def layout_cli( ignore_page_extraction, num_jobs, halt_fail, - device, ): """ Detect Layout (with optional image enhancement and reading order detection) @@ -223,7 +217,7 @@ def layout_cli( assert bool(image) != bool(dir_in), "Either -i (single input) or -di (directory) must be provided, but not both." eynollah = Eynollah( model_zoo=ctx.obj.model_zoo, - device=device, + device=ctx.obj.device, enable_plotting=enable_plotting, allow_enhancement=allow_enhancement, curved_line=curved_line, diff --git a/src/eynollah/cli/cli_ocr.py b/src/eynollah/cli/cli_ocr.py index 99e03c5..daeccbe 100644 --- a/src/eynollah/cli/cli_ocr.py +++ b/src/eynollah/cli/cli_ocr.py @@ -73,11 +73,6 @@ import click type=click.FloatRange(min=0.0, max=1.0), help="minimum OCR confidence threshold. Text lines with a lower confidence value will not be included in the output XML file.", ) -@click.option( - "--device", - "-D", - help="placement of computations in predictors for each model type; if none (by default), will try to use first available GPU or fall back to CPU; set string to force using a device (e.g. 'GPU0', 'GPU1' or 'CPU'). Can also be a comma-separated list of model category to device mappings (e.g. 'col_classifier:CPU,page:GPU0,*:GPU1')", -) @click.pass_context def ocr_cli( ctx, @@ -92,7 +87,6 @@ def ocr_cli( do_not_mask_with_textline_contour, batch_size, min_conf_value_of_textline_text, - device, ): """ Recognize text with a CNN/RNN or transformer ML model. @@ -101,11 +95,12 @@ def ocr_cli( from ..eynollah_ocr import Eynollah_ocr eynollah_ocr = Eynollah_ocr( model_zoo=ctx.obj.model_zoo, + device=ctx.obj.device, tr_ocr=tr_ocr, do_not_mask_with_textline_contour=do_not_mask_with_textline_contour, batch_size=batch_size, min_conf_value_of_textline_text=min_conf_value_of_textline_text, - device=device) + ) eynollah_ocr.run(overwrite=overwrite, dir_in=dir_in, dir_in_bin=dir_in_bin, diff --git a/src/eynollah/cli/cli_readingorder.py b/src/eynollah/cli/cli_readingorder.py index 9bb7092..ac52e38 100644 --- a/src/eynollah/cli/cli_readingorder.py +++ b/src/eynollah/cli/cli_readingorder.py @@ -22,19 +22,15 @@ import click type=click.Path(exists=True, file_okay=False), required=True, ) -@click.option( - "--device", - "-D", - help="placement of computations in predictors for each model type; if none (by default), will try to use first available GPU or fall back to CPU; set string to force using a device (e.g. 'GPU0', 'GPU1' or 'CPU'). Can also be a comma-separated list of model category to device mappings (e.g. 'col_classifier:CPU,page:GPU0,*:GPU1')", -) @click.pass_context -def readingorder_cli(ctx, input, dir_in, out, device): +def readingorder_cli(ctx, input, dir_in, out): """ Generate ReadingOrder with a ML model """ from ..mb_ro_on_layout import Reorder assert bool(input) != bool(dir_in), "Either -i (single input) or -di (directory) must be provided, but not both." - orderer = Reorder(model_zoo=ctx.obj.model_zoo, device=device) + orderer = Reorder(model_zoo=ctx.obj.model_zoo, + device=ctx.obj.device) orderer.run(xml_filename=input, dir_in=dir_in, dir_out=out, From 1ed633bc254ce76e6422d7496661ab5a173e5551 Mon Sep 17 00:00:00 2001 From: Robert Sachunsky Date: Tue, 12 May 2026 19:02:43 +0200 Subject: [PATCH 36/77] test_model_zoo: adapt (`load_models` instead of `load_model`) --- tests/test_model_zoo.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/test_model_zoo.py b/tests/test_model_zoo.py index 2042b28..341bc21 100644 --- a/tests/test_model_zoo.py +++ b/tests/test_model_zoo.py @@ -6,11 +6,11 @@ def test_trocr1( model_zoo = EynollahModelZoo(model_dir) try: from transformers import TrOCRProcessor, VisionEncoderDecoderModel - model_zoo.load_model('trocr_processor') - proc = model_zoo.get('trocr_processor', TrOCRProcessor) + model_zoo.load_models('trocr_processor', + ('ocr', 'tr')) + proc = model_zoo.get('trocr_processor') assert isinstance(proc, TrOCRProcessor) - model_zoo.load_model('ocr', 'tr') - model = model_zoo.get('ocr', VisionEncoderDecoderModel) + model = model_zoo.get('ocr') assert isinstance(model, VisionEncoderDecoderModel) except ImportError: pass From 87cce6c9636ff4a0c726fb2be0bbdc37b3838a32 Mon Sep 17 00:00:00 2001 From: Robert Sachunsky Date: Tue, 12 May 2026 19:03:32 +0200 Subject: [PATCH 37/77] CLI tests: add opt-in envvar `EYNOLLAH_OPTIONS` for device selection, model directory etc. --- tests/cli_tests/conftest.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/cli_tests/conftest.py b/tests/cli_tests/conftest.py index 601d76b..2e1501c 100644 --- a/tests/cli_tests/conftest.py +++ b/tests/cli_tests/conftest.py @@ -1,4 +1,5 @@ from typing import List +import os import pytest import logging @@ -31,6 +32,8 @@ def run_eynollah_ok_and_check_logs( subcommand, *args ] + if 'EYNOLLAH_OPTIONS' in os.environ: + args = os.environ['EYNOLLAH_OPTIONS'].split() + args if pytestconfig.getoption('verbose') > 0: args = ['-l', 'DEBUG'] + args caplog.set_level(logging.INFO) From be4fe8c263ed219e8d3df08e53e271d68556e7ff Mon Sep 17 00:00:00 2001 From: Robert Sachunsky Date: Tue, 12 May 2026 19:04:37 +0200 Subject: [PATCH 38/77] contour: drop unused functions depending on `rotation_image_new()` --- src/eynollah/utils/contour.py | 90 +---------------------------------- src/eynollah/utils/rotate.py | 4 -- 2 files changed, 1 insertion(+), 93 deletions(-) diff --git a/src/eynollah/utils/contour.py b/src/eynollah/utils/contour.py index f1a7a8e..1dbead1 100644 --- a/src/eynollah/utils/contour.py +++ b/src/eynollah/utils/contour.py @@ -11,7 +11,7 @@ from shapely.geometry.polygon import orient from shapely import set_precision, affinity from shapely.ops import unary_union, nearest_points -from .rotate import rotate_image, rotation_image_new +from .rotate import rotate_image def contours_in_same_horizon(cy_main_hor): """ @@ -120,94 +120,6 @@ def return_contours_of_interested_region(region_pre_p, label, min_area=0.0002, d dilate=dilate) return contours_imgs -def do_work_of_contours_in_image(contour, index_r_con, img, slope_first): - img_copy = np.zeros(img.shape[:2], dtype=np.uint8) - img_copy = cv2.fillPoly(img_copy, pts=[contour], color=1) - - img_copy = rotation_image_new(img_copy, -slope_first) - _, thresh = cv2.threshold(img_copy, 0, 255, 0) - - cont_int, _ = cv2.findContours(thresh, cv2.RETR_TREE, cv2.CHAIN_APPROX_SIMPLE) - - cont_int[0][:, 0, 0] = cont_int[0][:, 0, 0] + np.abs(img_copy.shape[1] - img.shape[1]) - cont_int[0][:, 0, 1] = cont_int[0][:, 0, 1] + np.abs(img_copy.shape[0] - img.shape[0]) - - return cont_int[0], index_r_con - -def get_textregion_contours_in_org_image_multi(cnts, img, slope_first, map=map): - if not len(cnts): - return [], [] - results = map(partial(do_work_of_contours_in_image, - img=img, - slope_first=slope_first, - ), - cnts, range(len(cnts))) - return tuple(zip(*results)) - -def get_textregion_contours_in_org_image(cnts, img, slope_first): - cnts_org = [] - # print(cnts,'cnts') - for i in range(len(cnts)): - img_copy = np.zeros(img.shape[:2], dtype=np.uint8) - img_copy = cv2.fillPoly(img_copy, pts=[cnts[i]], color=1) - - # plt.imshow(img_copy) - # plt.show() - - # print(img.shape,'img') - img_copy = rotation_image_new(img_copy, -slope_first) - ##print(img_copy.shape,'img_copy') - # plt.imshow(img_copy) - # plt.show() - - _, thresh = cv2.threshold(img_copy, 0, 255, 0) - - cont_int, _ = cv2.findContours(thresh, cv2.RETR_TREE, cv2.CHAIN_APPROX_SIMPLE) - cont_int[0][:, 0, 0] = cont_int[0][:, 0, 0] + np.abs(img_copy.shape[1] - img.shape[1]) - cont_int[0][:, 0, 1] = cont_int[0][:, 0, 1] + np.abs(img_copy.shape[0] - img.shape[0]) - # print(np.shape(cont_int[0])) - cnts_org.append(cont_int[0]) - - return cnts_org - -def get_textregion_confidences_old(cnts, img, slope_first): - zoom = 3 - img = cv2.resize(img, (img.shape[1] // zoom, - img.shape[0] // zoom), - interpolation=cv2.INTER_NEAREST) - cnts_org = [] - for cnt in cnts: - img_copy = np.zeros(img.shape[:2], dtype=np.uint8) - img_copy = cv2.fillPoly(img_copy, pts=[cnt // zoom], color=1) - - img_copy = rotation_image_new(img_copy, -slope_first).astype(np.uint8) - _, thresh = cv2.threshold(img_copy, 0, 255, 0) - - cont_int, _ = cv2.findContours(thresh, cv2.RETR_TREE, cv2.CHAIN_APPROX_SIMPLE) - cont_int[0][:, 0, 0] = cont_int[0][:, 0, 0] + np.abs(img_copy.shape[1] - img.shape[1]) - cont_int[0][:, 0, 1] = cont_int[0][:, 0, 1] + np.abs(img_copy.shape[0] - img.shape[0]) - cnts_org.append(cont_int[0] * zoom) - - return cnts_org - -def do_back_rotation_and_get_cnt_back(contour_par, index_r_con, img, slope_first, confidence_matrix): - img_copy = np.zeros(img.shape[:2], dtype=np.uint8) - img_copy = cv2.fillPoly(img_copy, pts=[contour_par], color=1) - confidence_matrix_mapped_with_contour = confidence_matrix * img_copy - confidence_contour = np.sum(confidence_matrix_mapped_with_contour) / float(np.sum(img_copy)) - - img_copy = rotation_image_new(img_copy, -slope_first).astype(np.uint8) - _, thresh = cv2.threshold(img_copy, 0, 255, 0) - - cont_int, _ = cv2.findContours(thresh, cv2.RETR_TREE, cv2.CHAIN_APPROX_SIMPLE) - if len(cont_int)==0: - cont_int = [contour_par] - confidence_contour = 0 - else: - cont_int[0][:, 0, 0] = cont_int[0][:, 0, 0] + np.abs(img_copy.shape[1] - img.shape[1]) - cont_int[0][:, 0, 1] = cont_int[0][:, 0, 1] + np.abs(img_copy.shape[0] - img.shape[0]) - return cont_int[0], index_r_con, confidence_contour - def get_region_confidences(cnts, confidence_matrix): if not len(cnts): return [] diff --git a/src/eynollah/utils/rotate.py b/src/eynollah/utils/rotate.py index 6651c4e..e45a438 100644 --- a/src/eynollah/utils/rotate.py +++ b/src/eynollah/utils/rotate.py @@ -2,10 +2,6 @@ import math import cv2 -def rotation_image_new(img, thetha): - rotated = rotate_image(img, thetha) - return rotate_max_area_new(img, rotated, thetha) - def rotate_image(img_patch, slope): (h, w) = img_patch.shape[:2] center = (w // 2, h // 2) From 17b311441a30cd3599b9414be8a734922aa6077d Mon Sep 17 00:00:00 2001 From: Robert Sachunsky Date: Tue, 12 May 2026 20:02:40 +0200 Subject: [PATCH 39/77] model_zoo: also parse comma/colon syntax for `device` in Torch case --- src/eynollah/model_zoo/model_zoo.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/eynollah/model_zoo/model_zoo.py b/src/eynollah/model_zoo/model_zoo.py index 7f3cd6c..f1d8824 100644 --- a/src/eynollah/model_zoo/model_zoo.py +++ b/src/eynollah/model_zoo/model_zoo.py @@ -154,7 +154,7 @@ class EynollahModelZoo: try: gpus = tf.config.list_physical_devices('GPU') if device: - if ',' in device: + if ':' in device: for spec in device.split(','): cat, dev = spec.split(':') if fnmatchcase(model_category, cat): @@ -235,6 +235,12 @@ class EynollahModelZoo: dev = torch.device('cpu') if not device and torch.cuda.is_available(): device = 'GPU' # try + if device and ':' in device: + for spec in device.split(','): + cat, dev = spec.split(':') + if fnmatchcase('ocr', cat): + device = dev + break if device and device.startswith('GPU'): try: dev = torch.device('cuda', int(device[3:] or 0)) From f329e10a805b57f18c454981757948e52dcabf9d Mon Sep 17 00:00:00 2001 From: Robert Sachunsky Date: Tue, 12 May 2026 20:04:41 +0200 Subject: [PATCH 40/77] test_layout: rm ignored `--allow_scaling` option --- tests/cli_tests/test_layout.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/tests/cli_tests/test_layout.py b/tests/cli_tests/test_layout.py index 7cbe013..503aeac 100644 --- a/tests/cli_tests/test_layout.py +++ b/tests/cli_tests/test_layout.py @@ -6,11 +6,12 @@ from ocrd_models.constants import NAMESPACES as NS "options", [ [], # defaults - #["--allow_scaling", "--curved-line"], - ["--allow_scaling", "--curved-line", "--full-layout"], - ["--allow_scaling", "--curved-line", "--full-layout", "--reading_order_machine_based"], + #["--curved-line"], + ["--curved-line", "--full-layout"], + ["--curved-line", "--full-layout", "--reading_order_machine_based"], # -ep ... - # -eoi ... + # --input_binary + # --ignore_page_extraction # --skip_layout_and_reading_order ], ids=str) def test_run_eynollah_layout_filename( From 481c286da9522d1117cc57f1775423e833076325 Mon Sep 17 00:00:00 2001 From: Robert Sachunsky Date: Tue, 19 May 2026 02:08:14 +0200 Subject: [PATCH 41/77] ModelZoo.load_model: no XLA compilation --- src/eynollah/model_zoo/model_zoo.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/eynollah/model_zoo/model_zoo.py b/src/eynollah/model_zoo/model_zoo.py index f1d8824..054552a 100644 --- a/src/eynollah/model_zoo/model_zoo.py +++ b/src/eynollah/model_zoo/model_zoo.py @@ -35,7 +35,7 @@ class EynollahModelZoo: self._overrides = [] if model_overrides: self.override_models(*model_overrides) - self._loaded: Dict[str, Predictor] = {} + self._loaded: Dict[str, Union[Predictor, AnyModel]] = {} @property def model_overrides(self): @@ -197,6 +197,7 @@ class EynollahModelZoo: model_path, compile=False, custom_objects=dict(PatchEncoder=PatchEncoder, Patches=Patches)) + model.make_predict_function() assert isinstance(model, KerasModel) model._name = model_category if resized: @@ -206,7 +207,10 @@ class EynollahModelZoo: model = wrap_layout_model_patched(model) model._name = model_category + '_patched' else: - model.jit_compile = True + # increases required VRAM, does not always work + # (depending on CUDA/libcudnn/TF version): + #model.jit_compile = True + pass if model_category == 'ocr': model = KerasModel( @@ -214,10 +218,9 @@ class EynollahModelZoo: model.get_layer(name="dense2").output, # type: ignore ) - model.make_predict_function() return model - def get(self, model_category: str) -> Predictor: + def get(self, model_category: str) -> Union[Predictor, AnyModel]: if model_category not in self._loaded: raise ValueError(f'Model "{model_category}" not previously loaded with "load_model(..)"') return self._loaded[model_category] From ffe5cdc5197b7e9c11e77b10969647ae8b1e2a75 Mon Sep 17 00:00:00 2001 From: Robert Sachunsky Date: Tue, 19 May 2026 02:09:49 +0200 Subject: [PATCH 42/77] ModelZoo.shutdown: drop extra `del` (already done by `shutdown()`) --- src/eynollah/model_zoo/model_zoo.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/eynollah/model_zoo/model_zoo.py b/src/eynollah/model_zoo/model_zoo.py index 054552a..3de8b6b 100644 --- a/src/eynollah/model_zoo/model_zoo.py +++ b/src/eynollah/model_zoo/model_zoo.py @@ -313,4 +313,3 @@ class EynollahModelZoo: for needle in list(self._loaded.keys()): if isinstance(self._loaded[needle], Predictor): self._loaded[needle].shutdown() - del self._loaded[needle] From 9efce5e9f2b5afb3c7cf1c44f4d262e383c737fd Mon Sep 17 00:00:00 2001 From: Robert Sachunsky Date: Tue, 19 May 2026 03:16:15 +0200 Subject: [PATCH 43/77] Predictor.shutdown: use `join()` instead of `terminate()` --- src/eynollah/predictor.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/eynollah/predictor.py b/src/eynollah/predictor.py index e1159e7..3c6890e 100644 --- a/src/eynollah/predictor.py +++ b/src/eynollah/predictor.py @@ -194,17 +194,18 @@ class Predictor(mp.context.SpawnProcess): def shutdown(self): # do not terminate from forked processor instances - if mp.parent_process() is None: + if not hasattr(self, 'model'): self.stopped.set() + self.join() self.taskq.close() self.taskq.cancel_join_thread() self.resultq.close() self.resultq.cancel_join_thread() self.logq.close() - self.terminate() + #self.terminate() else: del self.model def __del__(self): - #self.logger.debug(f"deinit of {self} in {mp.current_process().name}") + #self.logger.debug(f"deinit of {self.name} in {mp.current_process().name}") self.shutdown() From 86adaf299ade201b178fe851c7b4f884a680fc0c Mon Sep 17 00:00:00 2001 From: Robert Sachunsky Date: Tue, 19 May 2026 03:17:31 +0200 Subject: [PATCH 44/77] =?UTF-8?q?training.models.transformer=5Fblock:=20tf?= =?UTF-8?q?.reshape=20=E2=86=92=20Keras=20Reshape=20layer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/eynollah/training/models.py | 9 ++++----- src/eynollah/training/reload-models-v0.8.mk | 7 ++++--- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/eynollah/training/models.py b/src/eynollah/training/models.py index 3494249..f700d14 100644 --- a/src/eynollah/training/models.py +++ b/src/eynollah/training/models.py @@ -309,11 +309,10 @@ def transformer_block(img, # Skip connection 2. encoded_patches = Add()([x3, x2]) - encoded_patches = tf.reshape(encoded_patches, - [-1, - img.shape[1], - img.shape[2], - projection_dim // (patchsize_x * patchsize_y)]) + encoded_patches = Reshape(target_shape=(img.shape[1], + img.shape[2], + projection_dim // (patchsize_x * patchsize_y)), + name="reshape_patches")(encoded_patches) return encoded_patches def vit_resnet50_unet(num_patches, diff --git a/src/eynollah/training/reload-models-v0.8.mk b/src/eynollah/training/reload-models-v0.8.mk index b7a38dd..07be7cf 100644 --- a/src/eynollah/training/reload-models-v0.8.mk +++ b/src/eynollah/training/reload-models-v0.8.mk @@ -26,16 +26,17 @@ RELOADABLE_MODELS = \ all: $(RELOADABLE_MODELS) $(MODELS_DST)/%: $(MODELS_SRC)/% - mkdir -p $@ test -e $&1 | tee $(notdir $<).log - cp $ Date: Tue, 19 May 2026 03:20:24 +0200 Subject: [PATCH 45/77] =?UTF-8?q?reload=5Fweights:=20`save()`=20=E2=86=92?= =?UTF-8?q?=20`export()`=20w/=20`serve()`=20inference?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/eynollah/model_zoo/model_zoo.py | 12 +++++------- src/eynollah/training/train.py | 9 ++++++--- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/src/eynollah/model_zoo/model_zoo.py b/src/eynollah/model_zoo/model_zoo.py index 3de8b6b..815663e 100644 --- a/src/eynollah/model_zoo/model_zoo.py +++ b/src/eynollah/model_zoo/model_zoo.py @@ -191,14 +191,12 @@ class EynollahModelZoo: try: # avoid wasting VRAM on non-transformer models model = load_model(model_path, compile=False) - except Exception as e: - self.logger.error(e) - model = load_model( - model_path, compile=False, - custom_objects=dict(PatchEncoder=PatchEncoder, - Patches=Patches)) + assert isinstance(model, KerasModel) model.make_predict_function() - assert isinstance(model, KerasModel) + except ValueError: + model = tf.saved_model.load(model_path) + model.predict_on_batch = model.serve + model.input_shape = model.signatures.get('serving_default').inputs[0].shape model._name = model_category if resized: model = wrap_layout_model_resized(model) diff --git a/src/eynollah/training/train.py b/src/eynollah/training/train.py index de998fd..00ed6ee 100644 --- a/src/eynollah/training/train.py +++ b/src/eynollah/training/train.py @@ -562,7 +562,8 @@ def run(_config, if reload_weights: model.load_weights(dir_of_start_model).assert_existing_objects_matched().expect_partial() dir_save = os.path.join(dir_output, os.path.basename(os.path.normpath(dir_of_start_model))) - model.save(dir_save, include_optimizer=False) + #model.save(dir_save, include_optimizer=False) + model.export(dir_save) with open(os.path.join(dir_save, "config.json"), "w") as fp: json.dump(_config, fp) # encode dict into JSON _log.info("reloaded model from %s to %s", dir_of_start_model, dir_save) @@ -725,7 +726,8 @@ def run(_config, if reload_weights: model.load_weights(dir_of_start_model).assert_existing_objects_matched().expect_partial() dir_save = os.path.join(dir_output, os.path.basename(os.path.normpath(dir_of_start_model))) - model.save(dir_save, include_optimizer=False) + #model.save(dir_save, include_optimizer=False) + model.export(dir_save) with open(os.path.join(dir_save, "config.json"), "w") as fp: json.dump(_config, fp) # encode dict into JSON _log.info("reloaded model from %s to %s", dir_of_start_model, dir_save) @@ -843,7 +845,8 @@ def run(_config, if reload_weights: model.load_weights(dir_of_start_model).assert_existing_objects_matched().expect_partial() dir_save = os.path.join(dir_output, os.path.basename(os.path.normpath(dir_of_start_model))) - model.save(dir_save, include_optimizer=False) + #model.save(dir_save, include_optimizer=False) + model.export(dir_save) with open(os.path.join(dir_save, "config.json"), "w") as fp: json.dump(_config, fp) # encode dict into JSON _log.info("reloaded model from %s to %s", dir_of_start_model, dir_save) From 3de1407d1811d1c3135a3f353ef3260947ab3a93 Mon Sep 17 00:00:00 2001 From: Robert Sachunsky Date: Thu, 21 May 2026 02:38:20 +0200 Subject: [PATCH 46/77] drop unnecessary TF / Torch imports --- src/eynollah/cli/__init__.py | 4 ---- src/eynollah/extract_images.py | 7 ------- src/eynollah/eynollah_imports.py | 13 ------------- src/eynollah/eynollah_ocr.py | 4 ---- src/eynollah/mb_ro_on_layout.py | 4 ---- src/eynollah/model_zoo/model_zoo.py | 4 ++++ src/eynollah/ocrd_cli.py | 6 ++---- 7 files changed, 6 insertions(+), 36 deletions(-) delete mode 100644 src/eynollah/eynollah_imports.py diff --git a/src/eynollah/cli/__init__.py b/src/eynollah/cli/__init__.py index 43ed046..1584fa5 100644 --- a/src/eynollah/cli/__init__.py +++ b/src/eynollah/cli/__init__.py @@ -1,7 +1,3 @@ -# NOTE: For predictable order of imports of torch/shapely/tensorflow -# this must be the first import of the CLI! -from ..eynollah_imports import imported_libs - from .cli import main from .cli_binarize import binarize_cli from .cli_enhance import enhance_cli diff --git a/src/eynollah/extract_images.py b/src/eynollah/extract_images.py index 7a7e3f6..40476a3 100644 --- a/src/eynollah/extract_images.py +++ b/src/eynollah/extract_images.py @@ -9,7 +9,6 @@ import os import time from typing import Optional from pathlib import Path -import tensorflow as tf import numpy as np import cv2 @@ -64,12 +63,6 @@ class EynollahImageExtractor(Eynollah): t_start = time.time() - try: - for device in tf.config.list_physical_devices('GPU'): - tf.config.experimental.set_memory_growth(device, True) - except: - self.logger.warning("no GPU device available") - self.logger.info("Loading models...") self.setup_models() self.logger.info(f"Model initialization complete ({time.time() - t_start:.1f}s)") diff --git a/src/eynollah/eynollah_imports.py b/src/eynollah/eynollah_imports.py deleted file mode 100644 index 496406c..0000000 --- a/src/eynollah/eynollah_imports.py +++ /dev/null @@ -1,13 +0,0 @@ -""" -Load libraries with possible race conditions once. This must be imported as the first module of eynollah. -""" -import os -os.environ['TF_USE_LEGACY_KERAS'] = '1' # avoid Keras 3 after TF 2.15 - -from ocrd_utils import tf_disable_interactive_logs -from torch import * -tf_disable_interactive_logs() -import tensorflow.keras -from shapely import * -imported_libs = True -__all__ = ['imported_libs'] diff --git a/src/eynollah/eynollah_ocr.py b/src/eynollah/eynollah_ocr.py index 4470671..77ad98f 100644 --- a/src/eynollah/eynollah_ocr.py +++ b/src/eynollah/eynollah_ocr.py @@ -14,10 +14,6 @@ from cv2.typing import MatLike from xml.etree import ElementTree as ET from PIL import Image, ImageDraw import numpy as np -try: - import torch -except ImportError: - torch = None from .eynollah import Eynollah diff --git a/src/eynollah/mb_ro_on_layout.py b/src/eynollah/mb_ro_on_layout.py index 5725ba1..6c0477b 100644 --- a/src/eynollah/mb_ro_on_layout.py +++ b/src/eynollah/mb_ro_on_layout.py @@ -17,10 +17,6 @@ import cv2 import numpy as np import statistics -os.environ['TF_USE_LEGACY_KERAS'] = '1' # avoid Keras 3 after TF 2.15 -import tensorflow as tf -from tensorflow.keras.models import Model - from .eynollah import Eynollah from .model_zoo import EynollahModelZoo from .utils.resize import resize_image diff --git a/src/eynollah/model_zoo/model_zoo.py b/src/eynollah/model_zoo/model_zoo.py index 815663e..ec35a80 100644 --- a/src/eynollah/model_zoo/model_zoo.py +++ b/src/eynollah/model_zoo/model_zoo.py @@ -269,6 +269,10 @@ class EynollahModelZoo: """ Load decoder for OCR """ + os.environ['TF_USE_LEGACY_KERAS'] = '1' # avoid Keras 3 after TF 2.15 + from ocrd_utils import tf_disable_interactive_logs + tf_disable_interactive_logs() + from tensorflow.keras.layers import StringLookup characters = self._load_characters() diff --git a/src/eynollah/ocrd_cli.py b/src/eynollah/ocrd_cli.py index acd8d4e..effecb2 100644 --- a/src/eynollah/ocrd_cli.py +++ b/src/eynollah/ocrd_cli.py @@ -1,10 +1,8 @@ -# NOTE: For predictable order of imports of torch/shapely/tensorflow -# this must be the first import of the CLI! -from .eynollah_imports import imported_libs -from .processor import EynollahProcessor from click import command from ocrd.decorators import ocrd_cli_options, ocrd_cli_wrap_processor +from .processor import EynollahProcessor + @command() @ocrd_cli_options def main(*args, **kwargs): From 7f2bf715df02911325dea68228dca33dd9137fa7 Mon Sep 17 00:00:00 2001 From: Robert Sachunsky Date: Thu, 21 May 2026 02:39:59 +0200 Subject: [PATCH 47/77] ModelZoo.load_model: fix loading exported vs saved models --- src/eynollah/model_zoo/model_zoo.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/eynollah/model_zoo/model_zoo.py b/src/eynollah/model_zoo/model_zoo.py index ec35a80..a1f9a24 100644 --- a/src/eynollah/model_zoo/model_zoo.py +++ b/src/eynollah/model_zoo/model_zoo.py @@ -191,9 +191,8 @@ class EynollahModelZoo: try: # avoid wasting VRAM on non-transformer models model = load_model(model_path, compile=False) - assert isinstance(model, KerasModel) model.make_predict_function() - except ValueError: + except (AttributeError, ValueError): model = tf.saved_model.load(model_path) model.predict_on_batch = model.serve model.input_shape = model.signatures.get('serving_default').inputs[0].shape From 94a5e9da149967b4f3a54c87da7108035d1dd236 Mon Sep 17 00:00:00 2001 From: Robert Sachunsky Date: Thu, 21 May 2026 02:41:19 +0200 Subject: [PATCH 48/77] ModelZoo.load_model: avoid attempting to load exported models as Keras models (which causes a warning), but switch to TF-Serving import right away --- src/eynollah/model_zoo/model_zoo.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/eynollah/model_zoo/model_zoo.py b/src/eynollah/model_zoo/model_zoo.py index a1f9a24..b97911a 100644 --- a/src/eynollah/model_zoo/model_zoo.py +++ b/src/eynollah/model_zoo/model_zoo.py @@ -189,7 +189,8 @@ class EynollahModelZoo: self.override_models((model_category, model_variant, model_path_override)) model_path = self.model_path(model_category, model_variant) try: - # avoid wasting VRAM on non-transformer models + if model_path.is_dir() and not (model_path / "keras_metadata.pb").exists(): + raise ValueError() model = load_model(model_path, compile=False) model.make_predict_function() except (AttributeError, ValueError): From bf7ec0233df245ff14b18472fdaa2cb2bda51a1e Mon Sep 17 00:00:00 2001 From: Robert Sachunsky Date: Thu, 21 May 2026 02:43:34 +0200 Subject: [PATCH 49/77] =?UTF-8?q?ModelZoo.load=5Fmodel:=20use=20`memory=5F?= =?UTF-8?q?limit`=20instead=20of=20`memory=5Fgrowth`=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - growth strategy is more flexible, but uses much more VRAM - limit strategy needs to be calibrated to models (currently fixed), and batch size, but needs much less VRAM and is faster --- src/eynollah/model_zoo/model_zoo.py | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/src/eynollah/model_zoo/model_zoo.py b/src/eynollah/model_zoo/model_zoo.py index b97911a..c63a58d 100644 --- a/src/eynollah/model_zoo/model_zoo.py +++ b/src/eynollah/model_zoo/model_zoo.py @@ -169,7 +169,23 @@ class EynollahModelZoo: gpus = gpus[:1] # TF will always use first allowable tf.config.set_visible_devices(gpus, 'GPU') for device in gpus: - tf.config.experimental.set_memory_growth(device, True) + # tf.config.experimental.set_memory_growth(device, True) + # dynamic growth never frees memory (to avoid fragmentation), + # so the VRAM requirements end up much larger than feasible + # (for small GPUs); so try hard (calibrated) limits instead: + tf.config.set_logical_device_configuration( + device, + [tf.config.LogicalDeviceConfiguration(memory_limit={ + "binarization": 868, # due to bs 5 + "enhancement": 980, # due to bs 3 + "col_classifier": 210, + "page": 618, + "textline": 1680, # 954 for bs 1 + "region_1_2": 1580, + "region_fl_np": 1756, + "table": 1818, + "reading_order": 632, + }[model_category])]) vendor_name = ( tf.config.experimental.get_device_details(device) .get('device_name', 'unknown')) From f9f9130dbbb4c755d96e56f9855d9871db592806 Mon Sep 17 00:00:00 2001 From: Robert Sachunsky Date: Thu, 21 May 2026 03:21:36 +0200 Subject: [PATCH 50/77] do_order_of_regions: remove redundant+overcautious assertion --- src/eynollah/eynollah.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/eynollah/eynollah.py b/src/eynollah/eynollah.py index c632941..9db47ce 100644 --- a/src/eynollah/eynollah.py +++ b/src/eynollah/eynollah.py @@ -1148,7 +1148,6 @@ class Eynollah: boxes, textline_mask_tot ): - assert np.any(textline_mask_tot) self.logger.debug("enter do_order_of_regions") contours_only_text_parent = ensure_array(contours_only_text_parent) contours_only_text_parent_h = ensure_array(contours_only_text_parent_h) From d50bd7c650fe6413efe5d70bfdc235716d22e5d7 Mon Sep 17 00:00:00 2001 From: Robert Sachunsky Date: Thu, 21 May 2026 14:20:51 +0200 Subject: [PATCH 51/77] trocr: avoid warnings by passing `clean_up_tokenization_spaces=False` --- src/eynollah/eynollah_ocr.py | 50 ++++++++++++++++++++++++------------ 1 file changed, 33 insertions(+), 17 deletions(-) diff --git a/src/eynollah/eynollah_ocr.py b/src/eynollah/eynollah_ocr.py index 77ad98f..4371453 100644 --- a/src/eynollah/eynollah_ocr.py +++ b/src/eynollah/eynollah_ocr.py @@ -139,11 +139,14 @@ class Eynollah_ocr(Eynollah): cropped_lines = [] indexer_b_s = 0 - pixel_values_merged = self.model_zoo.get('trocr_processor')(imgs, return_tensors="pt").pixel_values + pixel_values_merged = self.model_zoo.get('trocr_processor')( + imgs, return_tensors="pt").pixel_values generated_ids_merged = self.model_zoo.get('ocr').generate( pixel_values_merged.to(self.device)) generated_text_merged = self.model_zoo.get('trocr_processor').batch_decode( - generated_ids_merged, skip_special_tokens=True) + generated_ids_merged, + skip_special_tokens=True, + clean_up_tokenization_spaces=False) extracted_texts = extracted_texts + generated_text_merged @@ -162,11 +165,14 @@ class Eynollah_ocr(Eynollah): cropped_lines = [] indexer_b_s = 0 - pixel_values_merged = self.model_zoo.get('trocr_processor')(imgs, return_tensors="pt").pixel_values + pixel_values_merged = self.model_zoo.get('trocr_processor')( + imgs, return_tensors="pt").pixel_values generated_ids_merged = self.model_zoo.get('ocr').generate( pixel_values_merged.to(self.device)) generated_text_merged = self.model_zoo.get('trocr_processor').batch_decode( - generated_ids_merged, skip_special_tokens=True) + generated_ids_merged, + skip_special_tokens=True, + clean_up_tokenization_spaces=False) extracted_texts = extracted_texts + generated_text_merged @@ -182,11 +188,14 @@ class Eynollah_ocr(Eynollah): cropped_lines = [] indexer_b_s = 0 - pixel_values_merged = self.model_zoo.get('trocr_processor')(imgs, return_tensors="pt").pixel_values + pixel_values_merged = self.model_zoo.get('trocr_processor')( + imgs, return_tensors="pt").pixel_values generated_ids_merged = self.model_zoo.get('ocr').generate( pixel_values_merged.to(self.device)) generated_text_merged = self.model_zoo.get('trocr_processor').batch_decode( - generated_ids_merged, skip_special_tokens=True) + generated_ids_merged, + skip_special_tokens=True, + clean_up_tokenization_spaces=False) extracted_texts = extracted_texts + generated_text_merged @@ -194,22 +203,23 @@ class Eynollah_ocr(Eynollah): cropped_lines.append(img_crop) cropped_lines_meging_indexing.append(0) indexer_b_s+=1 - + if indexer_b_s==self.b_s: imgs = cropped_lines[:] cropped_lines = [] indexer_b_s = 0 - - pixel_values_merged = self.model_zoo.get('trocr_processor')(imgs, return_tensors="pt").pixel_values + + pixel_values_merged = self.model_zoo.get('trocr_processor')( + imgs, return_tensors="pt").pixel_values generated_ids_merged = self.model_zoo.get('ocr').generate( pixel_values_merged.to(self.device)) generated_text_merged = self.model_zoo.get('trocr_processor').batch_decode( - generated_ids_merged, skip_special_tokens=True) - + generated_ids_merged, + skip_special_tokens=True, + clean_up_tokenization_spaces=False) + extracted_texts = extracted_texts + generated_text_merged - - - + indexer_text_region = indexer_text_region +1 if indexer_b_s!=0: @@ -217,9 +227,14 @@ class Eynollah_ocr(Eynollah): cropped_lines = [] indexer_b_s = 0 - pixel_values_merged = self.model_zoo.get('trocr_processor')(imgs, return_tensors="pt").pixel_values - generated_ids_merged = self.model_zoo.get('ocr').generate(pixel_values_merged.to(self.device)) - generated_text_merged = self.model_zoo.get('trocr_processor').batch_decode(generated_ids_merged, skip_special_tokens=True) + pixel_values_merged = self.model_zoo.get('trocr_processor')( + imgs, return_tensors="pt").pixel_values + generated_ids_merged = self.model_zoo.get('ocr').generate( + pixel_values_merged.to(self.device)) + generated_text_merged = self.model_zoo.get('trocr_processor').batch_decode( + generated_ids_merged, + skip_special_tokens=True, + clean_up_tokenization_spaces=False) extracted_texts = extracted_texts + generated_text_merged @@ -750,6 +765,7 @@ class Eynollah_ocr(Eynollah): indexer_textregion = indexer_textregion + 1 ET.register_namespace("",page_ns) + self.logger.info("output filename: '%s'", out_file_ocr) page_tree.write(out_file_ocr, xml_declaration=True, method='xml', encoding="utf-8", default_namespace=None) def run( From 1d67e65f11ad5266ba27262d38b4c49a7a864714 Mon Sep 17 00:00:00 2001 From: Robert Sachunsky Date: Thu, 21 May 2026 15:48:21 +0200 Subject: [PATCH 52/77] =?UTF-8?q?trocr:=20simplify,=20batch=20over=20entir?= =?UTF-8?q?e=20page=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - batching over entire page instead of region-wise (underfilling batches) - avoid copied redundant code --- src/eynollah/eynollah_ocr.py | 201 +++++++------------------------- src/eynollah/utils/utils_ocr.py | 6 + 2 files changed, 51 insertions(+), 156 deletions(-) diff --git a/src/eynollah/eynollah_ocr.py b/src/eynollah/eynollah_ocr.py index 4371453..747d2f5 100644 --- a/src/eynollah/eynollah_ocr.py +++ b/src/eynollah/eynollah_ocr.py @@ -14,6 +14,7 @@ from cv2.typing import MatLike from xml.etree import ElementTree as ET from PIL import Image, ImageDraw import numpy as np +from ocrd_utils import polygon_from_points, xywh_from_polygon from .eynollah import Eynollah @@ -31,6 +32,7 @@ from .utils.utils_ocr import ( preprocess_and_resize_image_for_ocrcnn_model, return_textlines_split_if_needed, rotate_image_with_padding, + batched, ) # TODO: refine typing @@ -90,143 +92,55 @@ class Eynollah_ocr(Eynollah): ) -> EynollahOcrResult: total_bb_coordinates = [] - - cropped_lines = [] cropped_lines_region_indexer = [] cropped_lines_meging_indexing = [] - extracted_texts = [] - indexer_text_region = 0 - indexer_b_s = 0 - - for nn in page_tree.getroot().iter(f'{{{page_ns}}}TextRegion'): - for child_textregion in nn: - if child_textregion.tag.endswith("TextLine"): - - for child_textlines in child_textregion: - if child_textlines.tag.endswith("Coords"): - cropped_lines_region_indexer.append(indexer_text_region) - p_h=child_textlines.attrib['points'].split(' ') - textline_coords = np.array( [ [int(x.split(',')[0]), - int(x.split(',')[1]) ] - for x in p_h] ) - x,y,w,h = cv2.boundingRect(textline_coords) - - total_bb_coordinates.append([x,y,w,h]) - - h2w_ratio = h/float(w) - - img_poly_on_img = np.copy(img) - mask_poly = np.zeros(img.shape) - mask_poly = cv2.fillPoly(mask_poly, pts=[textline_coords], color=(1, 1, 1)) - - mask_poly = mask_poly[y:y+h, x:x+w, :] - img_crop = img_poly_on_img[y:y+h, x:x+w, :] - img_crop[mask_poly==0] = 255 - - self.logger.debug("processing %d lines for '%s'", - len(cropped_lines), nn.attrib['id']) - if h2w_ratio > 0.1: - cropped_lines.append(resize_image(img_crop, - tr_ocr_input_height_and_width, - tr_ocr_input_height_and_width) ) - cropped_lines_meging_indexing.append(0) - indexer_b_s+=1 - if indexer_b_s==self.b_s: - imgs = cropped_lines[:] - cropped_lines = [] - indexer_b_s = 0 - - pixel_values_merged = self.model_zoo.get('trocr_processor')( - imgs, return_tensors="pt").pixel_values - generated_ids_merged = self.model_zoo.get('ocr').generate( - pixel_values_merged.to(self.device)) - generated_text_merged = self.model_zoo.get('trocr_processor').batch_decode( - generated_ids_merged, - skip_special_tokens=True, - clean_up_tokenization_spaces=False) - - extracted_texts = extracted_texts + generated_text_merged - - else: - splited_images, _ = return_textlines_split_if_needed(img_crop, None) - #print(splited_images) - if splited_images: - cropped_lines.append(resize_image(splited_images[0], - tr_ocr_input_height_and_width, - tr_ocr_input_height_and_width)) - cropped_lines_meging_indexing.append(1) - indexer_b_s+=1 - - if indexer_b_s==self.b_s: - imgs = cropped_lines[:] - cropped_lines = [] - indexer_b_s = 0 - - pixel_values_merged = self.model_zoo.get('trocr_processor')( - imgs, return_tensors="pt").pixel_values - generated_ids_merged = self.model_zoo.get('ocr').generate( - pixel_values_merged.to(self.device)) - generated_text_merged = self.model_zoo.get('trocr_processor').batch_decode( - generated_ids_merged, - skip_special_tokens=True, - clean_up_tokenization_spaces=False) - - extracted_texts = extracted_texts + generated_text_merged - - - cropped_lines.append(resize_image(splited_images[1], - tr_ocr_input_height_and_width, - tr_ocr_input_height_and_width)) - cropped_lines_meging_indexing.append(-1) - indexer_b_s+=1 - - if indexer_b_s==self.b_s: - imgs = cropped_lines[:] - cropped_lines = [] - indexer_b_s = 0 - - pixel_values_merged = self.model_zoo.get('trocr_processor')( - imgs, return_tensors="pt").pixel_values - generated_ids_merged = self.model_zoo.get('ocr').generate( - pixel_values_merged.to(self.device)) - generated_text_merged = self.model_zoo.get('trocr_processor').batch_decode( - generated_ids_merged, - skip_special_tokens=True, - clean_up_tokenization_spaces=False) - - extracted_texts = extracted_texts + generated_text_merged - - else: - cropped_lines.append(img_crop) - cropped_lines_meging_indexing.append(0) - indexer_b_s+=1 + for n_region, region in enumerate(page_tree.getroot().iter('{%s}TextRegion' % page_ns)): + for n_line, line in enumerate(region.iter('{%s}TextLine' % page_ns)): + cropped_lines_region_indexer.append(n_region) - if indexer_b_s==self.b_s: - imgs = cropped_lines[:] - cropped_lines = [] - indexer_b_s = 0 + coords = line.find('{%s}Coords' % page_ns) + if coords is None: + self.logger.warning("region '%s' line '%s' has no Coords", region.attrib['id'], line.attrib['id']) + continue + poly = np.array(polygon_from_points(coords.attrib['points'])).astype(int) + cont = poly[:, np.newaxis] + xywh = xywh_from_polygon(poly) + x, y, w, h = xywh['x'], xywh['y'], xywh['w'], xywh['h'] - pixel_values_merged = self.model_zoo.get('trocr_processor')( - imgs, return_tensors="pt").pixel_values - generated_ids_merged = self.model_zoo.get('ocr').generate( - pixel_values_merged.to(self.device)) - generated_text_merged = self.model_zoo.get('trocr_processor').batch_decode( - generated_ids_merged, - skip_special_tokens=True, - clean_up_tokenization_spaces=False) + total_bb_coordinates.append([x, y, w, h]) - extracted_texts = extracted_texts + generated_text_merged + img_crop = img[y: y + h, x: x + w] + mask_poly = np.zeros(img_crop.shape[:2], dtype=np.uint8) + mask_poly = cv2.fillPoly(mask_poly, pts=[cont - [x, y]], color=1) + img_crop[mask_poly == 0] = 255 # FIXME: or median color? - indexer_text_region = indexer_text_region +1 + if h > 0.1 * w: + cropped_lines.append(resize_image(img_crop, + tr_ocr_input_height_and_width, + tr_ocr_input_height_and_width) ) + cropped_lines_meging_indexing.append(0) + else: + splited_images, _ = return_textlines_split_if_needed(img_crop, None) + if splited_images: + cropped_lines.append(resize_image(splited_images[0], + tr_ocr_input_height_and_width, + tr_ocr_input_height_and_width)) + cropped_lines_meging_indexing.append(1) + cropped_lines.append(resize_image(splited_images[1], + tr_ocr_input_height_and_width, + tr_ocr_input_height_and_width)) + cropped_lines_meging_indexing.append(-1) + else: + cropped_lines.append(img_crop) + cropped_lines_meging_indexing.append(0) - if indexer_b_s!=0: - imgs = cropped_lines[:] - cropped_lines = [] - indexer_b_s = 0 - + + self.logger.debug("processing %d lines for %d regions", + len(cropped_lines), len(set(cropped_lines_region_indexer))) + for imgs in batched(cropped_lines, self.b_s): pixel_values_merged = self.model_zoo.get('trocr_processor')( imgs, return_tensors="pt").pixel_values generated_ids_merged = self.model_zoo.get('ocr').generate( @@ -235,40 +149,15 @@ class Eynollah_ocr(Eynollah): generated_ids_merged, skip_special_tokens=True, clean_up_tokenization_spaces=False) - extracted_texts = extracted_texts + generated_text_merged - - ####extracted_texts = [] - ####n_iterations = math.ceil(len(cropped_lines) / self.b_s) - - ####for i in range(n_iterations): - ####if i==(n_iterations-1): - ####n_start = i*self.b_s - ####imgs = cropped_lines[n_start:] - ####else: - ####n_start = i*self.b_s - ####n_end = (i+1)*self.b_s - ####imgs = cropped_lines[n_start:n_end] - ####pixel_values_merged = self.model_zoo.get('trocr_processor')(imgs, return_tensors="pt").pixel_values - ####generated_ids_merged = self.model_ocr.generate( - #### pixel_values_merged.to(self.device)) - ####generated_text_merged = self.model_zoo.get('trocr_processor').batch_decode( - #### generated_ids_merged, skip_special_tokens=True) - - ####extracted_texts = extracted_texts + generated_text_merged - del cropped_lines gc.collect() extracted_texts_merged = [extracted_texts[ind] - if cropped_lines_meging_indexing[ind]==0 - else extracted_texts[ind]+" "+extracted_texts[ind+1] - if cropped_lines_meging_indexing[ind]==1 - else None - for ind in range(len(cropped_lines_meging_indexing))] - - extracted_texts_merged = [ind for ind in extracted_texts_merged if ind is not None] - #print(extracted_texts_merged, len(extracted_texts_merged)) + if cropped_lines_meging_indexing[ind] == 0 + else extracted_texts[ind] + " " + extracted_texts[ind + 1] + for ind in range(len(cropped_lines_meging_indexing)) + if cropped_lines_meging_indexing[ind] >= 0] return EynollahOcrResult( extracted_texts_merged=extracted_texts_merged, diff --git a/src/eynollah/utils/utils_ocr.py b/src/eynollah/utils/utils_ocr.py index 93d1137..6914fee 100644 --- a/src/eynollah/utils/utils_ocr.py +++ b/src/eynollah/utils/utils_ocr.py @@ -1,5 +1,6 @@ import math import copy +from itertools import islice import numpy as np import cv2 @@ -502,3 +503,8 @@ def return_rnn_cnn_ocr_of_given_textlines(image, ocr_textline_in_textregion.append(text_textline) ocr_all_textlines.append(ocr_textline_in_textregion) return ocr_all_textlines + +def batched(iterable, n): + iterator = iter(iterable) + while batch := tuple(islice(iterator, n)): + yield batch From f3649adbf24eb6b4d189846d67eeed88f153ea06 Mon Sep 17 00:00:00 2001 From: Robert Sachunsky Date: Thu, 21 May 2026 17:23:11 +0200 Subject: [PATCH 53/77] trocr: apply `do_not_mask_with_textline_contour` here, too --- src/eynollah/eynollah_ocr.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/eynollah/eynollah_ocr.py b/src/eynollah/eynollah_ocr.py index 747d2f5..f1b155b 100644 --- a/src/eynollah/eynollah_ocr.py +++ b/src/eynollah/eynollah_ocr.py @@ -113,9 +113,10 @@ class Eynollah_ocr(Eynollah): total_bb_coordinates.append([x, y, w, h]) img_crop = img[y: y + h, x: x + w] - mask_poly = np.zeros(img_crop.shape[:2], dtype=np.uint8) - mask_poly = cv2.fillPoly(mask_poly, pts=[cont - [x, y]], color=1) - img_crop[mask_poly == 0] = 255 # FIXME: or median color? + if not self.do_not_mask_with_textline_contour: + mask_poly = np.zeros(img_crop.shape[:2], dtype=np.uint8) + mask_poly = cv2.fillPoly(mask_poly, pts=[cont - [x, y]], color=1) + img_crop[mask_poly == 0] = 255 # FIXME: or median color? if h > 0.1 * w: cropped_lines.append(resize_image(img_crop, From 000e4ac8d8b66f874b0423c627c9bdccab880b57 Mon Sep 17 00:00:00 2001 From: Robert Sachunsky Date: Thu, 21 May 2026 17:25:39 +0200 Subject: [PATCH 54/77] trocr: extract confidence, too --- src/eynollah/eynollah_ocr.py | 27 ++++++++++++++++++++------- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/src/eynollah/eynollah_ocr.py b/src/eynollah/eynollah_ocr.py index f1b155b..faeb042 100644 --- a/src/eynollah/eynollah_ocr.py +++ b/src/eynollah/eynollah_ocr.py @@ -90,12 +90,14 @@ class Eynollah_ocr(Eynollah): page_ns, tr_ocr_input_height_and_width, ) -> EynollahOcrResult: + import torch total_bb_coordinates = [] cropped_lines = [] cropped_lines_region_indexer = [] cropped_lines_meging_indexing = [] extracted_texts = [] + extracted_confs = [] for n_region, region in enumerate(page_tree.getroot().iter('{%s}TextRegion' % page_ns)): for n_line, line in enumerate(region.iter('{%s}TextLine' % page_ns)): @@ -142,15 +144,20 @@ class Eynollah_ocr(Eynollah): self.logger.debug("processing %d lines for %d regions", len(cropped_lines), len(set(cropped_lines_region_indexer))) for imgs in batched(cropped_lines, self.b_s): - pixel_values_merged = self.model_zoo.get('trocr_processor')( + pixel_values = self.model_zoo.get('trocr_processor')( imgs, return_tensors="pt").pixel_values - generated_ids_merged = self.model_zoo.get('ocr').generate( - pixel_values_merged.to(self.device)) - generated_text_merged = self.model_zoo.get('trocr_processor').batch_decode( - generated_ids_merged, + output = self.model_zoo.get('ocr').generate( + pixel_values.to(self.device), + output_scores=True, + return_dict_in_generate=True) + conf = torch.max(torch.softmax(torch.cat( + output.scores, dim=0), dim=1), dim=1).values.tolist() + text = self.model_zoo.get('trocr_processor').batch_decode( + output.sequences, skip_special_tokens=True, clean_up_tokenization_spaces=False) - extracted_texts = extracted_texts + generated_text_merged + extracted_confs.extend(conf) + extracted_texts.extend(text) del cropped_lines gc.collect() @@ -159,10 +166,15 @@ class Eynollah_ocr(Eynollah): else extracted_texts[ind] + " " + extracted_texts[ind + 1] for ind in range(len(cropped_lines_meging_indexing)) if cropped_lines_meging_indexing[ind] >= 0] + extracted_confs_merged = [extracted_confs[ind] + if cropped_lines_meging_indexing[ind] == 0 + else 0.5 * (extracted_confs[ind] + extracted_confs[ind + 1]) + for ind in range(len(cropped_lines_meging_indexing)) + if cropped_lines_meging_indexing[ind] >= 0] return EynollahOcrResult( extracted_texts_merged=extracted_texts_merged, - extracted_conf_value_merged=None, + extracted_conf_value_merged=extracted_confs_merged, cropped_lines_region_indexer=cropped_lines_region_indexer, total_bb_coordinates=total_bb_coordinates, ) @@ -618,6 +630,7 @@ class Eynollah_ocr(Eynollah): has_textline = False for child_textregion in nn: + # FIXME: should remove Word level, if it already exists if child_textregion.tag.endswith("TextLine"): is_textline_text = False From 074753a98e647b83c99358034a610e1f5364c79f Mon Sep 17 00:00:00 2001 From: Robert Sachunsky Date: Thu, 21 May 2026 17:25:53 +0200 Subject: [PATCH 55/77] ModelZoo: fix Torch device selection --- src/eynollah/model_zoo/model_zoo.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/src/eynollah/model_zoo/model_zoo.py b/src/eynollah/model_zoo/model_zoo.py index c63a58d..be41d2a 100644 --- a/src/eynollah/model_zoo/model_zoo.py +++ b/src/eynollah/model_zoo/model_zoo.py @@ -247,9 +247,9 @@ class EynollahModelZoo: if variant == 'tr': from transformers import VisionEncoderDecoderModel import torch - ret = VisionEncoderDecoderModel.from_pretrained(model_dir) - assert isinstance(ret, VisionEncoderDecoderModel) - dev = torch.device('cpu') + model = VisionEncoderDecoderModel.from_pretrained(model_dir) + assert isinstance(model, VisionEncoderDecoderModel) + device0 = torch.device('cpu') if not device and torch.cuda.is_available(): device = 'GPU' # try if device and ':' in device: @@ -260,17 +260,17 @@ class EynollahModelZoo: break if device and device.startswith('GPU'): try: - dev = torch.device('cuda', int(device[3:] or 0)) - name = torch.cuda.get_device_name(dev) - self.logger.info("using GPU %s (%s) for model ocr:tr", dev, name) + device0 = torch.device('cuda', int(device[3:] or 0)) + name = torch.cuda.get_device_name(device0) + self.logger.info("using GPU %s (%s) for model ocr:tr", device0, name) except: self.logger.exception("cannot configure GPU device") - dev = torch.device('cpu') - if dev.type == 'cuda': - ret.to(dev) + device0 = torch.device('cpu') + if device0.type == 'cuda': + model.to(device0) else: self.logger.warning("no GPU device available") - return ret + return model return self.load_model('ocr', model_variant=variant, device=device) From ea41dcae1d401ac2b4b74403d4cc515d8da6c4ba Mon Sep 17 00:00:00 2001 From: Robert Sachunsky Date: Thu, 21 May 2026 17:52:27 +0200 Subject: [PATCH 56/77] trocr: use beam search instead of greedy decoding --- src/eynollah/eynollah_ocr.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/eynollah/eynollah_ocr.py b/src/eynollah/eynollah_ocr.py index faeb042..b94853b 100644 --- a/src/eynollah/eynollah_ocr.py +++ b/src/eynollah/eynollah_ocr.py @@ -90,7 +90,6 @@ class Eynollah_ocr(Eynollah): page_ns, tr_ocr_input_height_and_width, ) -> EynollahOcrResult: - import torch total_bb_coordinates = [] cropped_lines = [] @@ -148,10 +147,16 @@ class Eynollah_ocr(Eynollah): imgs, return_tensors="pt").pixel_values output = self.model_zoo.get('ocr').generate( pixel_values.to(self.device), + # beam search instead of greedy decoding: + num_beams=4, + # also return probability output_scores=True, return_dict_in_generate=True) - conf = torch.max(torch.softmax(torch.cat( - output.scores, dim=0), dim=1), dim=1).values.tolist() + if output.sequences_scores is not None: + # log-prob averaged over length + conf = output.sequences_scores.exp().clamp(0.0, 1.0).tolist() + else: + conf = [1.0] * len(output.sequences) text = self.model_zoo.get('trocr_processor').batch_decode( output.sequences, skip_special_tokens=True, From f3a93983c0848bc02785a24656b9524f90dfd22a Mon Sep 17 00:00:00 2001 From: Robert Sachunsky Date: Thu, 21 May 2026 22:50:13 +0200 Subject: [PATCH 57/77] ModelZoo: add `ocr` key for `memory_limit` --- src/eynollah/model_zoo/model_zoo.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/eynollah/model_zoo/model_zoo.py b/src/eynollah/model_zoo/model_zoo.py index be41d2a..2bac7f3 100644 --- a/src/eynollah/model_zoo/model_zoo.py +++ b/src/eynollah/model_zoo/model_zoo.py @@ -185,6 +185,7 @@ class EynollahModelZoo: "region_fl_np": 1756, "table": 1818, "reading_order": 632, + "ocr": 850, }[model_category])]) vendor_name = ( tf.config.experimental.get_device_details(device) From 0836230c6b29384e7ecb6700d92573518dac64ef Mon Sep 17 00:00:00 2001 From: Robert Sachunsky Date: Thu, 21 May 2026 22:50:53 +0200 Subject: [PATCH 58/77] utils_ocr: avoid module-level import of TF --- src/eynollah/utils/utils_ocr.py | 8 +++++++- tests/cli_tests/test_ocr.py | 4 ++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/eynollah/utils/utils_ocr.py b/src/eynollah/utils/utils_ocr.py index 6914fee..817406c 100644 --- a/src/eynollah/utils/utils_ocr.py +++ b/src/eynollah/utils/utils_ocr.py @@ -4,7 +4,9 @@ from itertools import islice import numpy as np import cv2 -import tensorflow as tf +# avoid module-level import: +# import tensorflow as tf +# (wait for tf-keras and logging setup in ModelZoo.load_model) from scipy.signal import find_peaks from scipy.ndimage import gaussian_filter1d from PIL import Image, ImageDraw, ImageFont @@ -13,6 +15,8 @@ from .resize import resize_image def decode_batch_predictions(pred, num_to_char, max_len = 128): + import tensorflow as tf + # input_len is the product of the batch size and the # number of time steps. input_len = np.ones(pred.shape[0]) * pred.shape[1] @@ -40,6 +44,8 @@ def decode_batch_predictions(pred, num_to_char, max_len = 128): def distortion_free_resize(image, img_size): + import tensorflow as tf + w, h = img_size image = tf.image.resize(image, size=(h, w), preserve_aspect_ratio=True) diff --git a/tests/cli_tests/test_ocr.py b/tests/cli_tests/test_ocr.py index 6bf3080..cf34e06 100644 --- a/tests/cli_tests/test_ocr.py +++ b/tests/cli_tests/test_ocr.py @@ -30,7 +30,7 @@ def test_run_eynollah_ocr_filename( '-o', str(outfile.parent), ] + options, [ - # FIXME: ocr has no logging! + 'output filename:' ] ) assert outfile.exists() @@ -57,7 +57,7 @@ def test_run_eynollah_ocr_directory( '-o', str(outdir), ], [ - # FIXME: ocr has no logging! + 'output filename:' ] ) assert len(list(outdir.iterdir())) == 2 From 26afc5ddab34c2d0c966a706f8b283b891280209 Mon Sep 17 00:00:00 2001 From: Robert Sachunsky Date: Fri, 22 May 2026 12:35:44 +0200 Subject: [PATCH 59/77] ModelZoo: ensure exported TensorShape is converted to plain tuple --- src/eynollah/model_zoo/model_zoo.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/eynollah/model_zoo/model_zoo.py b/src/eynollah/model_zoo/model_zoo.py index 2bac7f3..d5e69a2 100644 --- a/src/eynollah/model_zoo/model_zoo.py +++ b/src/eynollah/model_zoo/model_zoo.py @@ -207,13 +207,14 @@ class EynollahModelZoo: model_path = self.model_path(model_category, model_variant) try: if model_path.is_dir() and not (model_path / "keras_metadata.pb").exists(): + # short-cut to avoid warning for exported models raise ValueError() model = load_model(model_path, compile=False) model.make_predict_function() except (AttributeError, ValueError): model = tf.saved_model.load(model_path) model.predict_on_batch = model.serve - model.input_shape = model.signatures.get('serving_default').inputs[0].shape + model.input_shape = tuple(model.signatures.get('serving_default').inputs[0].shape) model._name = model_category if resized: model = wrap_layout_model_resized(model) From 9801129aa6da83af1562fd14b47a37b67011de5a Mon Sep 17 00:00:00 2001 From: Robert Sachunsky Date: Fri, 22 May 2026 12:37:07 +0200 Subject: [PATCH 60/77] estimate_skew_contours: ensure retval is always float --- src/eynollah/utils/contour.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/eynollah/utils/contour.py b/src/eynollah/utils/contour.py index 1dbead1..eda60e9 100644 --- a/src/eynollah/utils/contour.py +++ b/src/eynollah/utils/contour.py @@ -330,7 +330,7 @@ def estimate_skew_contours(contours): if not np.any(usable): raise ValueError("not enough contours with consistent length") if np.count_nonzero(usable) == 1: - return angle_in[usable] + return angle_in[usable][0] # 4. there is no way to distinguish between +90 and -89.9 here, # so map to [0,180] when calculating averages, then map back to [-90,90] # (we don't want -90 and +89 to average zero, or +1 and +179 to average 90) From c4a7eec5b3195cd3114a0d2b54de723c806e5bab Mon Sep 17 00:00:00 2001 From: Robert Sachunsky Date: Wed, 27 May 2026 01:58:21 +0200 Subject: [PATCH 61/77] models: cosmetics - using `Reshape`, do not pass `target_shape` as kwarg - add a default `name` for `Patches` and `PatchEncoder` --- src/eynollah/patch_encoder.py | 8 ++++---- src/eynollah/training/models.py | 20 ++++++++++---------- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/src/eynollah/patch_encoder.py b/src/eynollah/patch_encoder.py index f163132..610f0b4 100644 --- a/src/eynollah/patch_encoder.py +++ b/src/eynollah/patch_encoder.py @@ -6,8 +6,8 @@ from tensorflow.keras import layers, models class PatchEncoder(layers.Layer): # 441=21*21 # 14*14 # 28*28 - def __init__(self, num_patches=441, projection_dim=64): - super().__init__() + def __init__(self, num_patches=441, projection_dim=64, name='encode_patches'): + super().__init__(name=name) self.num_patches = num_patches self.projection_dim = projection_dim self.projection = layers.Dense(self.projection_dim) @@ -23,8 +23,8 @@ class PatchEncoder(layers.Layer): **super().get_config()) class Patches(layers.Layer): - def __init__(self, patch_size_x=1, patch_size_y=1): - super().__init__() + def __init__(self, patch_size_x=1, patch_size_y=1, name='extract_patches'): + super().__init__(name=name) self.patch_size_x = patch_size_x self.patch_size_y = patch_size_y diff --git a/src/eynollah/training/models.py b/src/eynollah/training/models.py index f700d14..c5510f8 100644 --- a/src/eynollah/training/models.py +++ b/src/eynollah/training/models.py @@ -309,9 +309,9 @@ def transformer_block(img, # Skip connection 2. encoded_patches = Add()([x3, x2]) - encoded_patches = Reshape(target_shape=(img.shape[1], - img.shape[2], - projection_dim // (patchsize_x * patchsize_y)), + encoded_patches = Reshape((img.shape[1], + img.shape[2], + projection_dim // (patchsize_x * patchsize_y)), name="reshape_patches")(encoded_patches) return encoded_patches @@ -464,23 +464,23 @@ def cnn_rnn_ocr_model(image_height=None, image_width=None, n_classes=None, max_s new_shape2 = (x2d.shape[1]*x2d.shape[2], x2d.shape[3]) new_shape4 = (x4d.shape[1]*x4d.shape[2], x4d.shape[3]) - x = Reshape(target_shape=new_shape, name="reshape")(x) - x2d = Reshape(target_shape=new_shape2, name="reshape2")(x2d) - x4d = Reshape(target_shape=new_shape4, name="reshape4")(x4d) + x = Reshape(new_shape, name="reshape")(x) + x2d = Reshape(new_shape2, name="reshape2")(x2d) + x4d = Reshape(new_shape4, name="reshape4")(x4d) xrnnorg = Bidirectional(LSTM(image_width, return_sequences=True, dropout=0.25))(x) xrnn2d = Bidirectional(LSTM(image_width, return_sequences=True, dropout=0.25))(x2d) xrnn4d = Bidirectional(LSTM(image_width, return_sequences=True, dropout=0.25))(x4d) - xrnn2d = Reshape(target_shape=(1, xrnn2d.shape[1], xrnn2d.shape[2]), name="reshape6")(xrnn2d) - xrnn4d = Reshape(target_shape=(1, xrnn4d.shape[1], xrnn4d.shape[2]), name="reshape8")(xrnn4d) + xrnn2d = Reshape((1, xrnn2d.shape[1], xrnn2d.shape[2]), name="reshape6")(xrnn2d) + xrnn4d = Reshape((1, xrnn4d.shape[1], xrnn4d.shape[2]), name="reshape8")(xrnn4d) xrnn2dup = UpSampling2D(size=(1, 2), interpolation="nearest")(xrnn2d) xrnn4dup = UpSampling2D(size=(1, 4), interpolation="nearest")(xrnn4d) - xrnn2dup = Reshape(target_shape=(xrnn2dup.shape[2], xrnn2dup.shape[3]), name="reshape10")(xrnn2dup) - xrnn4dup = Reshape(target_shape=(xrnn4dup.shape[2], xrnn4dup.shape[3]), name="reshape12")(xrnn4dup) + xrnn2dup = Reshape((xrnn2dup.shape[2], xrnn2dup.shape[3]), name="reshape10")(xrnn2dup) + xrnn4dup = Reshape((xrnn4dup.shape[2], xrnn4dup.shape[3]), name="reshape12")(xrnn4dup) addition = Add()([xrnnorg, xrnn2dup, xrnn4dup]) From faef1967f87fc497fa988ea4c4d0a2e454d3f633 Mon Sep 17 00:00:00 2001 From: Robert Sachunsky Date: Thu, 28 May 2026 17:32:02 +0200 Subject: [PATCH 62/77] models.cnn_rnn_ocr_model: add `inference` option, drop model name --- src/eynollah/training/models.py | 34 ++++++++++++++++----------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/src/eynollah/training/models.py b/src/eynollah/training/models.py index c5510f8..eb621c6 100644 --- a/src/eynollah/training/models.py +++ b/src/eynollah/training/models.py @@ -422,11 +422,11 @@ def machine_based_reading_order_model(n_classes,input_height=224,input_width=224 return model -def cnn_rnn_ocr_model(image_height=None, image_width=None, n_classes=None, max_seq=None): - input_img = Input(shape=(image_height, image_width, 3), name="image") +def cnn_rnn_ocr_model(image_height=None, image_width=None, n_classes=None, max_len=None, inference=False): + inputs = Input(shape=(image_height, image_width, 3), name="image") labels = Input(name="label", shape=(None,)) - x = Conv2D(64,kernel_size=(3,3),padding="same")(input_img) + x = Conv2D(64,kernel_size=(3,3),padding="same")(inputs) x = BatchNormalization(name="bn1")(x) x = Activation("relu", name="relu1")(x) x = Conv2D(64,kernel_size=(3,3),padding="same")(x) @@ -458,44 +458,44 @@ def cnn_rnn_ocr_model(image_height=None, image_width=None, n_classes=None, max_s x = Activation("relu", name="relu8")(x) x2d = MaxPooling2D(pool_size=(1,2),strides=(1,2))(x) x4d = MaxPooling2D(pool_size=(1,2),strides=(1,2))(x2d) - new_shape = (x.shape[1]*x.shape[2], x.shape[3]) new_shape2 = (x2d.shape[1]*x2d.shape[2], x2d.shape[3]) new_shape4 = (x4d.shape[1]*x4d.shape[2], x4d.shape[3]) - + x = Reshape(new_shape, name="reshape")(x) x2d = Reshape(new_shape2, name="reshape2")(x2d) x4d = Reshape(new_shape4, name="reshape4")(x4d) - + xrnnorg = Bidirectional(LSTM(image_width, return_sequences=True, dropout=0.25))(x) xrnn2d = Bidirectional(LSTM(image_width, return_sequences=True, dropout=0.25))(x2d) xrnn4d = Bidirectional(LSTM(image_width, return_sequences=True, dropout=0.25))(x4d) - + xrnn2d = Reshape((1, xrnn2d.shape[1], xrnn2d.shape[2]), name="reshape6")(xrnn2d) xrnn4d = Reshape((1, xrnn4d.shape[1], xrnn4d.shape[2]), name="reshape8")(xrnn4d) - xrnn2dup = UpSampling2D(size=(1, 2), interpolation="nearest")(xrnn2d) xrnn4dup = UpSampling2D(size=(1, 4), interpolation="nearest")(xrnn4d) - + xrnn2dup = Reshape((xrnn2dup.shape[2], xrnn2dup.shape[3]), name="reshape10")(xrnn2dup) xrnn4dup = Reshape((xrnn4dup.shape[2], xrnn4dup.shape[3]), name="reshape12")(xrnn4dup) addition = Add()([xrnnorg, xrnn2dup, xrnn4dup]) - + addition_rnn = Bidirectional(LSTM(image_width, return_sequences=True, dropout=0.25))(addition) - - out = Conv1D(max_seq, 1, data_format="channels_first")(addition_rnn) + + out = Conv1D(max_len, 1, data_format="channels_first")(addition_rnn) out = BatchNormalization(name="bn9")(out) out = Activation("relu", name="relu9")(out) #out = Conv1D(n_classes, 1, activation='relu', data_format="channels_last")(out) out = Dense(n_classes, activation="softmax", name="dense2")(out) - # Add CTC layer for calculating CTC loss at each step. - output = CTCLayer(name="ctc_loss")(labels, out) - - model = Model(inputs=(input_img, labels), outputs=output, name="handwriting_recognizer") + if inference: + return Model(inputs, out) + + # Add CTC layer for calculating CTC loss at each step. + out = CTCLayer(name="ctc_loss")(labels, out) + + return Model((inputs, labels), out) - return model From 093030f503e0032c97260540ad42c671f3f0d6a1 Mon Sep 17 00:00:00 2001 From: Robert Sachunsky Date: Thu, 28 May 2026 17:37:45 +0200 Subject: [PATCH 63/77] =?UTF-8?q?train/models:=20move=20all=20model=20buil?= =?UTF-8?q?ders=20to=20`models.get=5Fmodel()`=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - models: add new `get_model()`, passing in Sacred config to capture builder function arguments - train: fewer imports - train: no need to pass `custom_objects` if loading with `compile=False` (and we custom-compile later, anyway) --- src/eynollah/training/models.py | 49 ++++++++++++++++++++ src/eynollah/training/train.py | 81 ++++----------------------------- 2 files changed, 57 insertions(+), 73 deletions(-) diff --git a/src/eynollah/training/models.py b/src/eynollah/training/models.py index eb621c6..83058ee 100644 --- a/src/eynollah/training/models.py +++ b/src/eynollah/training/models.py @@ -499,3 +499,52 @@ def cnn_rnn_ocr_model(image_height=None, image_width=None, n_classes=None, max_l return Model((inputs, labels), out) +def get_model(config, logger): + from sacred.config import create_captured_function + + task = config['task'] + if task in ["segmentation", "enhancement", "binarization"]: + if config['backbone_type'] == 'nontransformer': + builder = resnet50_unet + else: + num_patches_x, num_patches_y = config['transformer_num_patches_xy'] + num_patches = num_patches_x * num_patches_y + + if config['transformer_cnn_first']: + builder = vit_resnet50_unet + multiple = 32 + else: + builder = vit_resnet50_unet_transformer_before_cnn + multiple = 1 + + assert config['input_height'] == ( + num_patches_y * config['transformer_patchsize_y'] * multiple), ( + "transformer_patchsize_y or transformer_num_patches_xy height value error: " + "input_height should be equal to " + "(transformer_num_patches_xy height value * transformer_patchsize_y * %d)" % multiple) + assert config['input_width'] == ( + num_patches_x * config['transformer_patchsize_x'] * multiple), ( + "transformer_patchsize_x or transformer_num_patches_xy width value error: " + "input_width should be equal to " + "(transformer_num_patches_xy width value * transformer_patchsize_x * %d)" % multiple) + assert 0 == (config['transformer_projection_dim'] % + (config['transformer_patchsize_y'] * + config['transformer_patchsize_x'])), ( + "transformer_projection_dim error: " + "The remainder when parameter transformer_projection_dim is divided by " + "(transformer_patchsize_y*transformer_patchsize_x) should be zero") + + config['num_patches'] = num_patches + elif task == "cnn-rnn-ocr": + builder = cnn_rnn_ocr_model + elif task=='classification': + builder = resnet50_classifier + elif task=='reading_order': + builder = machine_based_reading_order_model + else: + raise ValueError("unknown model task '%s'" % task) + + builder = create_captured_function(builder) + builder.config = config + builder.logger = logger + return builder() diff --git a/src/eynollah/training/train.py b/src/eynollah/training/train.py index 00ed6ee..2cb42b6 100644 --- a/src/eynollah/training/train.py +++ b/src/eynollah/training/train.py @@ -17,7 +17,6 @@ from tensorflow.keras.layers import StringLookup from tensorflow.keras.utils import image_dataset_from_directory from tensorflow.keras.backend import one_hot from sacred import Experiment -from sacred.config import create_captured_function import numpy as np import cv2 @@ -32,16 +31,9 @@ from .metrics import ( connected_components_loss, ) from .models import ( - PatchEncoder, - Patches, - machine_based_reading_order_model, - resnet50_classifier, - resnet50_unet, - vit_resnet50_unet, - vit_resnet50_unet_transformer_before_cnn, - cnn_rnn_ocr_model, RESNET50_WEIGHTS_PATH, - RESNET50_WEIGHTS_URL + RESNET50_WEIGHTS_URL, + get_model ) from .utils import ( generate_arrays_from_folder_reading_order, @@ -477,58 +469,12 @@ def run(_config, if task == "enhancement": assert not is_loss_soft_dice, "for enhancement, soft_dice loss does not apply" assert not weighted_loss, "for enhancement, weighted loss does not apply" + if continue_training: - custom_objects = dict() - if is_loss_soft_dice: - custom_objects.update(soft_dice_loss=soft_dice_loss) - elif weighted_loss: - custom_objects.update(loss=weighted_categorical_crossentropy(weights)) - if backbone_type == 'transformer': - custom_objects.update(PatchEncoder=PatchEncoder, - Patches=Patches) - model = load_model(dir_of_start_model, compile=False, - custom_objects=custom_objects) + model = load_model(dir_of_start_model, compile=False) else: index_start = 0 - if backbone_type == 'nontransformer': - model = resnet50_unet(n_classes, - input_height, - input_width, - task, - weight_decay, - pretraining) - else: - num_patches_x = transformer_num_patches_xy[0] - num_patches_y = transformer_num_patches_xy[1] - num_patches = num_patches_x * num_patches_y - - if transformer_cnn_first: - model_builder = vit_resnet50_unet - multiple = 32 - else: - model_builder = vit_resnet50_unet_transformer_before_cnn - multiple = 1 - - assert input_height == ( - num_patches_y * transformer_patchsize_y * multiple), ( - "transformer_patchsize_y or transformer_num_patches_xy height value error: " - "input_height should be equal to " - "(transformer_num_patches_xy height value * transformer_patchsize_y * %d)" % multiple) - assert input_width == ( - num_patches_x * transformer_patchsize_x * multiple), ( - "transformer_patchsize_x or transformer_num_patches_xy width value error: " - "input_width should be equal to " - "(transformer_num_patches_xy width value * transformer_patchsize_x * %d)" % multiple) - assert 0 == (transformer_projection_dim % - (transformer_patchsize_y * transformer_patchsize_x)), ( - "transformer_projection_dim error: " - "The remainder when parameter transformer_projection_dim is divided by " - "(transformer_patchsize_y*transformer_patchsize_x) should be zero") - - model_builder = create_captured_function(model_builder) - model_builder.config = _config - model_builder.logger = _log - model = model_builder(num_patches) + model = get_model(_config, _log) assert model is not None #if you want to see the model structure just uncomment model summary. @@ -709,10 +655,7 @@ def run(_config, model = load_model(dir_of_start_model) else: index_start = 0 - model = cnn_rnn_ocr_model(image_height=input_height, - image_width=input_width, - n_classes=n_classes, - max_seq=max_len) + model = get_model(_config, _log) #initial_learning_rate = 1e-4 #decay_steps = int (n_epochs * ( len_dataset / n_batch )) #alpha = 0.01 @@ -774,11 +717,7 @@ def run(_config, model = load_model(dir_of_start_model, compile=False) else: index_start = 0 - model = resnet50_classifier(n_classes, - input_height, - input_width, - weight_decay, - pretraining) + model = get_model(_config, _log) model.compile(loss='categorical_crossentropy', optimizer=Adam(learning_rate=0.001), # rs: why not learning_rate? @@ -830,11 +769,7 @@ def run(_config, model = load_model(dir_of_start_model, compile=False) else: index_start = 0 - model = machine_based_reading_order_model(n_classes, - input_height, - input_width, - weight_decay, - pretraining) + model = get_model(_config, _log) #f1score_tot = [0] model.compile(loss="binary_crossentropy", From 62b55a3809ff37711f2ef21b1f31f6c715408cd8 Mon Sep 17 00:00:00 2001 From: Robert Sachunsky Date: Thu, 28 May 2026 17:42:55 +0200 Subject: [PATCH 64/77] =?UTF-8?q?train=20params:=20drop=20`reload=5Fweight?= =?UTF-8?q?s`,=20re-use=20`dir=5Fof=5Fstart=5Fmodel`=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - drop ad-hoc configuration parameter `reload_weights` (used for conversion/export of models for inference, to be replaced by extra CLI) - re-interprete `dir_of_start_model` to also load weights if not `continue_training` --- src/eynollah/training/train.py | 56 +++++++++------------------------- 1 file changed, 14 insertions(+), 42 deletions(-) diff --git a/src/eynollah/training/train.py b/src/eynollah/training/train.py index 2cb42b6..f4cf08b 100644 --- a/src/eynollah/training/train.py +++ b/src/eynollah/training/train.py @@ -347,10 +347,9 @@ def config_params(): dir_output = None # Directory where the augmented training data and the model checkpoints will be saved. pretraining = False # Set to true to (down)load pretrained weights of ResNet50 encoder. save_interval = None # frequency for writing model checkpoints (positive integer for number of batches saved under "model_step_{batch:04d}", otherwise epoch saved under "model_{epoch:02d}") - reload_weights = False # Set true to build new model from config, load weights from dir_of_start_model, save under dir_output and exit. continue_training = False # Whether to continue training an existing model. + dir_of_start_model = '' # Directory of model checkpoint to load to continue training or load weights from. (E.g. if you already trained for 3 epochs, set "dir_of_start_model=dir_output/model_03".) if continue_training: - dir_of_start_model = '' # Directory of model checkpoint to load to continue training. (E.g. if you already trained for 3 epochs, set "dir_of_start_model=dir_output/model_03".) index_start = 0 # Epoch counter initial value to continue training. (E.g. if you already trained for 3 epochs, set "index_start=3" to continue naming checkpoints model_04, model_05 etc.) data_is_provided = False # Whether the preprocessed input data (subdirectories "images" and "labels" in both subdirectories "train" and "eval" of "dir_output") has already been generated (in the first epoch of a previous run). @@ -371,7 +370,6 @@ def run(_config, weight_decay, learning_rate, continue_training, - reload_weights, save_interval, augmentation, # dependent config keys need a default, @@ -475,6 +473,9 @@ def run(_config, else: index_start = 0 model = get_model(_config, _log) + if dir_of_start_model: + model.load_weights(dir_of_start_model).assert_existing_objects_matched().expect_partial() + _log.info("reloaded weights from %s", dir_of_start_model) assert model is not None #if you want to see the model structure just uncomment model summary. @@ -505,16 +506,6 @@ def run(_config, optimizer=Adam(learning_rate=learning_rate), metrics=metrics) - if reload_weights: - model.load_weights(dir_of_start_model).assert_existing_objects_matched().expect_partial() - dir_save = os.path.join(dir_output, os.path.basename(os.path.normpath(dir_of_start_model))) - #model.save(dir_save, include_optimizer=False) - model.export(dir_save) - with open(os.path.join(dir_save, "config.json"), "w") as fp: - json.dump(_config, fp) # encode dict into JSON - _log.info("reloaded model from %s to %s", dir_of_start_model, dir_save) - return - if not data_is_provided: # first create a directory in output for both training and evaluations # in order to flow data from these directories. @@ -656,6 +647,10 @@ def run(_config, else: index_start = 0 model = get_model(_config, _log) + if dir_of_start_model: + model.load_weights(dir_of_start_model).assert_existing_objects_matched().expect_partial() + _log.info("reloaded weights from %s", dir_of_start_model) + #initial_learning_rate = 1e-4 #decay_steps = int (n_epochs * ( len_dataset / n_batch )) #alpha = 0.01 @@ -666,16 +661,6 @@ def run(_config, #print(model.summary()) - if reload_weights: - model.load_weights(dir_of_start_model).assert_existing_objects_matched().expect_partial() - dir_save = os.path.join(dir_output, os.path.basename(os.path.normpath(dir_of_start_model))) - #model.save(dir_save, include_optimizer=False) - model.export(dir_save) - with open(os.path.join(dir_save, "config.json"), "w") as fp: - json.dump(_config, fp) # encode dict into JSON - _log.info("reloaded model from %s to %s", dir_of_start_model, dir_save) - return - # todo: use Dataset.map() on Dataset.list_files() def get_dataset(dir_img, dir_lab): def gen(): @@ -718,20 +703,14 @@ def run(_config, else: index_start = 0 model = get_model(_config, _log) + if dir_of_start_model: + model.load_weights(dir_of_start_model).assert_existing_objects_matched().expect_partial() + _log.info("reloaded weights from %s", dir_of_start_model) model.compile(loss='categorical_crossentropy', optimizer=Adam(learning_rate=0.001), # rs: why not learning_rate? metrics=['accuracy', F1Score(average='macro', name='f1')]) - if reload_weights: - model.load_weights(dir_of_start_model).assert_existing_objects_matched().expect_partial() - dir_save = os.path.join(dir_output, os.path.basename(os.path.normpath(dir_of_start_model))) - model.save(dir_save, include_optimizer=False) - with open(os.path.join(dir_save, "config.json"), "w") as fp: - json.dump(_config, fp) # encode dict into JSON - _log.info("reloaded model from %s to %s", dir_of_start_model, dir_save) - return - list_classes = list(classification_classes_name.values()) data_args = dict(label_mode="categorical", class_names=list_classes, @@ -770,6 +749,9 @@ def run(_config, else: index_start = 0 model = get_model(_config, _log) + if dir_of_start_model: + model.load_weights(dir_of_start_model).assert_existing_objects_matched().expect_partial() + _log.info("reloaded weights from %s", dir_of_start_model) #f1score_tot = [0] model.compile(loss="binary_crossentropy", @@ -777,16 +759,6 @@ def run(_config, optimizer=Adam(learning_rate=0.0001), # rs: why not learning_rate? metrics=['accuracy']) - if reload_weights: - model.load_weights(dir_of_start_model).assert_existing_objects_matched().expect_partial() - dir_save = os.path.join(dir_output, os.path.basename(os.path.normpath(dir_of_start_model))) - #model.save(dir_save, include_optimizer=False) - model.export(dir_save) - with open(os.path.join(dir_save, "config.json"), "w") as fp: - json.dump(_config, fp) # encode dict into JSON - _log.info("reloaded model from %s to %s", dir_of_start_model, dir_save) - return - dir_flow_train_imgs = os.path.join(dir_train, 'images') dir_flow_train_labels = os.path.join(dir_train, 'labels') From f833a516e7fb20949f2919f4792f2aaf315ead06 Mon Sep 17 00:00:00 2001 From: Robert Sachunsky Date: Thu, 28 May 2026 17:48:21 +0200 Subject: [PATCH 65/77] =?UTF-8?q?training:=20add=20CLI=20command=20`conver?= =?UTF-8?q?t`=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - move `train_cli` from cli.py to train.py, add docstring - add `convert_cli`: - load any (supported) model format (i.e. not exported TF-Serving or ONNX) - if SavedModel format with `config.json` present, and `--rebuild` is requested, create new model from `models.get_model()` for this configuration, and load weights - if model type is `cnn-rnn-ocr` and configuration is still for training (`ctc_loss`), then extract inference model - apply requested `--format` conversion: HDF5, Keras native, Keras SavedModel, TF-Serving SavedModel or ONNX - if output format is directory (i.e. SavedModel), then copy over `config.json`, too - reload-models-v0.8.mk: - adapt recipe for converter CLI (i.e. `--format tf-serving` w/ `--rebuild` if possible) - add targets for other useful data formats - extend list of model names to all current models (as all benefit from TF-Serving export) - cancel ONNX conversion for vision transformer models (as these do not work, yet) --- src/eynollah/training/cli.py | 11 +- src/eynollah/training/convert.py | 107 ++++++++++++++++++++ src/eynollah/training/reload-models-v0.8.mk | 85 ++++++++++------ src/eynollah/training/train.py | 21 ++++ src/eynollah/training/weights_ensembling.py | 1 + 5 files changed, 187 insertions(+), 38 deletions(-) create mode 100644 src/eynollah/training/convert.py diff --git a/src/eynollah/training/cli.py b/src/eynollah/training/cli.py index ae14f04..ccabb82 100644 --- a/src/eynollah/training/cli.py +++ b/src/eynollah/training/cli.py @@ -7,17 +7,11 @@ import sys from .build_model_load_pretrained_weights_and_save import build_model_load_pretrained_weights_and_save from .generate_gt_for_training import main as generate_gt_cli from .inference import main as inference_cli -from .train import ex +from .train import train_cli +from .convert import convert_cli from .extract_line_gt import linegt_cli from .weights_ensembling import ensemble_cli -@click.command(context_settings=dict( - ignore_unknown_options=True, -)) -@click.argument('SACRED_ARGS', nargs=-1, type=click.UNPROCESSED) -def train_cli(sacred_args): - ex.run_commandline([sys.argv[0]] + list(sacred_args)) - @click.group('training') def main(): pass @@ -26,5 +20,6 @@ main.add_command(build_model_load_pretrained_weights_and_save) main.add_command(generate_gt_cli, 'generate-gt') main.add_command(inference_cli, 'inference') main.add_command(train_cli, 'train') +main.add_command(convert_cli, 'convert') main.add_command(linegt_cli, 'export_textline_images_and_text') main.add_command(ensemble_cli, 'ensembling') diff --git a/src/eynollah/training/convert.py b/src/eynollah/training/convert.py new file mode 100644 index 0000000..dd4271f --- /dev/null +++ b/src/eynollah/training/convert.py @@ -0,0 +1,107 @@ +import os +from pathlib import Path +from shutil import copy2 +import logging + +import click + +@click.command(context_settings=dict( + help_option_names=['-h', '--help'], + show_default=True)) +@click.option( + "--rebuild", + "-r", + help="build new model from code and then load existing weights (requires input in SavedModel directory format with config.json present)", + is_flag=True +) +@click.option( + "--format", + "-f", + "format_", + help="data format to convert to", + type=click.Choice(["hdf5", "keras", "tf", "tf-serving", "onnx"]), + default="tf" +) +@click.option( + "--in", + "-i", + "in_", + help="path to input model (file in hdf5 / keras format, or directory in tf format)", + required=True, + type=click.Path(exists=True, dir_okay=True) +) +@click.option( + "--out", + "-o", + help="path to output model (file in hdf5 / keras / onnx format, or directory in tf / tf-serving format)", + required=True, + type=click.Path(exists=False, dir_okay=True) +) +def convert_cli(rebuild, format_, in_, out): + """ + convert models for inference + + Load model from path, optionally by rebuilding, convert to output format and write model to path. + """ + os.environ['TF_USE_LEGACY_KERAS'] = '1' # avoid Keras 3 after TF 2.15 + from ocrd_utils import tf_disable_interactive_logs + tf_disable_interactive_logs() + + import tensorflow as tf + from tensorflow.keras.models import load_model + from tensorflow.keras.models import Model as KerasModel + + model_path = Path(in_) + config_path = model_path / "config.json" + if model_path.is_dir(): + assert (model_path / "keras_metadata.pb").exists(), ( + "input directory must be Keras model in SavedModel format") + if rebuild: + from .train import ex + from .models import get_model + + assert config_path.exists(), ( + "rebuilding requires input model in SavedModel format with config.json") + + # merge defaults with existing config file + ex.add_config(str(config_path)) + # some models deviate between training and inference + ex.add_config(inference=True) + # just retrieve final config (via pseudo-run) + ex.main(lambda: 0) + config = ex.run(options={'--loglevel': 'ERROR'}).config + # use the config to capture the model builder + model = get_model(config, logging.root) + model.load_weights(model_path).assert_existing_objects_matched().expect_partial() + else: + model = load_model(model_path, compile=False) + + if isinstance(model, KerasModel): + # cnn-rnn-ocr task deviates between training and inference + try: + model.get_layer(name='ctc_loss') + except ValueError: + pass + else: + model = KerasModel( + model.get_layer(name='image').input, + model.get_layer(name='dense2').output) + + if format_ in ["hdf5", "keras", "tf"]: + kwargs = {"save_format": {"hdf5": "h5"}.get(format_, format_)} + if format_ != "keras": + kwargs["include_optimizer"] = False + model.save(out, **kwargs) + elif format_ == "tf-serving": + model.export(out) + elif format_ == "onnx": + import tf2onnx + tf2onnx.convert.from_keras(model, opset=18, output_path=out) + else: + raise ValueError("unknown output format '%s'" % format_) + + # copy config.json if possible + if config_path.exists() and format_ in ['tf', 'tf-serving']: + copy2(config_path, Path(out) / config_path.name) + + diff --git a/src/eynollah/training/reload-models-v0.8.mk b/src/eynollah/training/reload-models-v0.8.mk index 07be7cf..9855f0f 100644 --- a/src/eynollah/training/reload-models-v0.8.mk +++ b/src/eynollah/training/reload-models-v0.8.mk @@ -4,39 +4,65 @@ MODELS_SRC = models_eynollah MODELS_DST = reloaded/models_eynollah -# $(MODELS_DST)/eynollah-binarization_20210425 \ -# $(MODELS_DST)/eynollah-column-classifier_20210425 \ -# $(MODELS_DST)/eynollah-enhancement_20210425 \ -# $(MODELS_DST)/eynollah-main-regions-aug-rotation_20210425 \ -# $(MODELS_DST)/eynollah-main-regions-aug-scaling_20210425 \ -# $(MODELS_DST)/eynollah-main-regions-ensembled_20210425 \ -# $(MODELS_DST)/eynollah-main-regions_20220314 \ -# $(MODELS_DST)/eynollah-main-regions_20231127_672_org_ens_11_13_16_17_18 \ -# $(MODELS_DST)/eynollah-tables_20210319 \ -# $(MODELS_DST)/model_eynollah_ocr_cnnrnn_20250930 \ +# eynollah-main-regions-aug-rotation_20210425 +# eynollah-main-regions-aug-scaling_20210425 +# eynollah-main-regions-ensembled_20210425 +# eynollah-main-regions_20220314 +# eynollah-main-regions_20231127_672_org_ens_11_13_16_17_18 +# eynollah-tables_20210319 -RELOADABLE_MODELS = \ - $(MODELS_DST)/model_eynollah_page_extraction_20250915 \ - $(MODELS_DST)/model_eynollah_reading_order_20250824 \ - $(MODELS_DST)/modelens_e_l_all_sp_0_1_2_3_4_171024 \ - $(MODELS_DST)/modelens_full_lay_1__4_3_091124 \ - $(MODELS_DST)/modelens_table_0t4_201124 \ - $(MODELS_DST)/modelens_textline_0_1__2_4_16092024 +CURRENT_MODELS := +CURRENT_MODELS += model_eynollah_page_extraction_20250915 +CURRENT_MODELS += model_eynollah_reading_order_20250824 +CURRENT_MODELS += modelens_e_l_all_sp_0_1_2_3_4_171024 +CURRENT_MODELS += modelens_full_lay_1__4_3_091124 +CURRENT_MODELS += modelens_table_0t4_201124 +CURRENT_MODELS += modelens_textline_0_1__2_4_16092024 +CURRENT_MODELS += model_eynollah_ocr_cnnrnn_20250930 +CURRENT_MODELS += eynollah-binarization_20210425 +CURRENT_MODELS += eynollah-column-classifier_20210425 +CURRENT_MODELS += eynollah-enhancement_20210425 -all: $(RELOADABLE_MODELS) +all: tf-serving + +tf-serving: $(CURRENT_MODELS:%=$(MODELS_DST)/%) +keras: $(CURRENT_MODELS:%=$(MODELS_DST)/%.keras) +hdf5: $(CURRENT_MODELS:%=$(MODELS_DST)/%.h5) +onnx: $(CURRENT_MODELS:%=$(MODELS_DST)/%.onnx) $(MODELS_DST)/%: $(MODELS_SRC)/% - test -e $&1 | tee $(notdir $<).log + eynollah-training convert \ + $(and $(wildcard $&1 | tee $(notdir $<).tf-serving.log + +$(MODELS_DST)/%.keras: $(MODELS_SRC)/% + eynollah-training convert \ + $(and $(wildcard $&1 | tee $(notdir $<).keras.log + +$(MODELS_DST)/%.h5: $(MODELS_SRC)/% + eynollah-training convert \ + $(and $(wildcard $&1 | tee $(notdir $<).hdf5.log + +$(MODELS_DST)/%.onnx: $(MODELS_SRC)/% + if jq -e '.task == "segmentation" and .backbone_type == "transformer"' $/dev/null; then \ + echo skipping $@: vision transformer architecture currently does not work with ONNX; else \ + eynollah-training convert \ + $(and $(wildcard $&1 | tee $(notdir $<).onnx.log; fi compare: for i in `find $(MODELS_DST) -mindepth 2`;do \ @@ -44,6 +70,5 @@ compare: du -bs $$n $$i ; \ done - clear: rm -rf $(MODELS_DST) diff --git a/src/eynollah/training/train.py b/src/eynollah/training/train.py index f4cf08b..62d8e51 100644 --- a/src/eynollah/training/train.py +++ b/src/eynollah/training/train.py @@ -2,6 +2,7 @@ import os import sys import io import json +import click from tqdm import tqdm import requests @@ -791,3 +792,23 @@ def run(_config, model_dir = os.path.join(dir_out,'model_best') model.save(model_dir) ''' + +@click.command(context_settings=dict( + ignore_unknown_options=True, +)) +@click.argument('SACRED_ARGS', nargs=-1, type=click.UNPROCESSED) +def train_cli(sacred_args): + """ + train model on extracted GT + + SACRED_ARGS as per CLI interface of Sacred, cf. + https://sacred.readthedocs.io/en/stable/command_line.html: + + \b + To configure the learning task, pass the string `with`, + followed by any number of + - config JSON file paths + - parameter overrides in the form of key=value + (where the later settings will override the former). + """ + ex.run_commandline([sys.argv[0]] + list(sacred_args)) diff --git a/src/eynollah/training/weights_ensembling.py b/src/eynollah/training/weights_ensembling.py index e3ede24..f651c56 100644 --- a/src/eynollah/training/weights_ensembling.py +++ b/src/eynollah/training/weights_ensembling.py @@ -43,6 +43,7 @@ def run_ensembling(model_dirs, out_dir): @click.option( "--in", "-i", + "in_", help="input directory of checkpoint models to be read", multiple=True, required=True, From 13f2f81c45fc31bad000dfe99c61c0e602b5846e Mon Sep 17 00:00:00 2001 From: Robert Sachunsky Date: Thu, 28 May 2026 18:08:08 +0200 Subject: [PATCH 66/77] =?UTF-8?q?ModelZoo:=20support=20inference=20with=20?= =?UTF-8?q?ONNX/TensorRT=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - comment out ad-hoc conversion/loading of autosized models - refactor predictor backends for model types into separate functions - only attempt inference conversion of cnn-rnn-ocr model if applicable (`ctc_loss` layer still present) - apply VRAM limits across model types (Keras, TF-Serving, ONNX) - apply TF device selection across model types (Keras, TF-Serving) - implement predictor backend for ONNX models: - using onnxruntime - covering CUDA and TensorRT providers - trying to support manual device selection - hiding session management details - converting float32 to float16 --- src/eynollah/model_zoo/model_zoo.py | 209 ++++++++++++++++++++-------- 1 file changed, 151 insertions(+), 58 deletions(-) diff --git a/src/eynollah/model_zoo/model_zoo.py b/src/eynollah/model_zoo/model_zoo.py index d5e69a2..0a68203 100644 --- a/src/eynollah/model_zoo/model_zoo.py +++ b/src/eynollah/model_zoo/model_zoo.py @@ -14,6 +14,19 @@ from .default_specs import DEFAULT_MODEL_SPECS from .types import AnyModel, T +MODEL_VRAM_LIMITS = { + "binarization": 868, # due to bs 5 + "enhancement": 980, # due to bs 3 + "col_classifier": 210, + "page": 618, + "textline": 1680, # 954 for bs 1 + "region_1_2": 1580, + "region_fl_np": 1756, + "table": 1818, + "reading_order": 632, + "ocr": 850, +} + class EynollahModelZoo: """ Wrapper class that handles storage and loading of models for all eynollah runners. @@ -73,6 +86,10 @@ class EynollahModelZoo: if model_path.suffix == '.h5' and Path(model_path.stem).exists(): # prefer SavedModel over HDF5 format if it exists model_path = Path(model_path.stem) + if model_path.with_suffix('.onnx').exists(): + # prefer ONNX over SavedModel format if it exists + model_path = model_path.with_suffix('.onnx') + return model_path def load_models( @@ -136,20 +153,34 @@ class EynollahModelZoo: """ Load any model """ - os.environ['TF_USE_LEGACY_KERAS'] = '1' # avoid Keras 3 after TF 2.15 + if model_path_override: + self.override_models((model_category, model_variant, model_path_override)) + model_path = self.model_path(model_category, model_variant) + + if model_path.is_dir() and (model_path / "keras_metadata.pb").exists(): + # Keras model + model = self._load_keras_model(model_category, model_path, device=device) + elif model_path.is_dir(): + # TF-Serving model + model = self._load_serving_model(model_category, model_path, device=device) + elif model_path.suffix == '.onnx': + # ONNX model + model = self._load_onnx_model(model_category, model_path, device=device) + else: + raise ValueError("unknown model type for '%s'" % str(model_path)) + model._name = model_category + return model + + def get(self, model_category: str) -> Union[Predictor, AnyModel]: + if model_category not in self._loaded: + raise ValueError(f'Model "{model_category}" not previously loaded with "load_model(..)"') + return self._loaded[model_category] + + def _configure_tf_device(self, model_category, device=''): from ocrd_utils import tf_disable_interactive_logs tf_disable_interactive_logs() - import tensorflow as tf - from tensorflow.keras.models import load_model - from tensorflow.keras.models import Model as KerasModel - from ..patch_encoder import ( - PatchEncoder, - Patches, - wrap_layout_model_patched, - wrap_layout_model_resized, - ) cuda = False try: gpus = tf.config.list_physical_devices('GPU') @@ -175,18 +206,8 @@ class EynollahModelZoo: # (for small GPUs); so try hard (calibrated) limits instead: tf.config.set_logical_device_configuration( device, - [tf.config.LogicalDeviceConfiguration(memory_limit={ - "binarization": 868, # due to bs 5 - "enhancement": 980, # due to bs 3 - "col_classifier": 210, - "page": 618, - "textline": 1680, # 954 for bs 1 - "region_1_2": 1580, - "region_fl_np": 1756, - "table": 1818, - "reading_order": 632, - "ocr": 850, - }[model_category])]) + [tf.config.LogicalDeviceConfiguration( + memory_limit=MODEL_VRAM_LIMITS[model_category])]) vendor_name = ( tf.config.experimental.get_device_details(device) .get('device_name', 'unknown')) @@ -194,52 +215,124 @@ class EynollahModelZoo: self.logger.info("using GPU %s (%s) for model %s", device.name, vendor_name, - model_category + ( - "_patched" if patched else - "_resized" if resized else "")) + model_category # + ( + # "_patched" if patched else + # "_resized" if resized else "") + ) except RuntimeError: self.logger.exception("cannot configure GPU devices") if not cuda: self.logger.warning("no GPU device available") - if model_path_override: - self.override_models((model_category, model_variant, model_path_override)) - model_path = self.model_path(model_category, model_variant) - try: - if model_path.is_dir() and not (model_path / "keras_metadata.pb").exists(): - # short-cut to avoid warning for exported models - raise ValueError() - model = load_model(model_path, compile=False) - model.make_predict_function() - except (AttributeError, ValueError): - model = tf.saved_model.load(model_path) - model.predict_on_batch = model.serve - model.input_shape = tuple(model.signatures.get('serving_default').inputs[0].shape) - model._name = model_category - if resized: - model = wrap_layout_model_resized(model) - model._name = model_category + '_resized' - elif patched: - model = wrap_layout_model_patched(model) - model._name = model_category + '_patched' - else: - # increases required VRAM, does not always work - # (depending on CUDA/libcudnn/TF version): - #model.jit_compile = True - pass + def _load_keras_model(self, model_category, model_path, device=''): + os.environ['TF_USE_LEGACY_KERAS'] = '1' # avoid Keras 3 after TF 2.15 + from ocrd_utils import tf_disable_interactive_logs + tf_disable_interactive_logs() + + from tensorflow.keras.models import load_model + from tensorflow.keras.models import Model as KerasModel + + self._configure_tf_device(model_category, device=device) + + model = load_model(model_path, compile=False) + + # from ..patch_encoder import ( + # wrap_layout_model_patched, + # wrap_layout_model_resized, + # ) + # if resized: + # model = wrap_layout_model_resized(model) + # model._name = model_category + '_resized' + # elif patched: + # model = wrap_layout_model_patched(model) + # model._name = model_category + '_patched' if model_category == 'ocr': - model = KerasModel( - model.get_layer(name="image").input, # type: ignore - model.get_layer(name="dense2").output, # type: ignore - ) + # cnn-rnn-ocr task model may not be in inference mode, yet + try: + model.get_layer(name='ctc_loss') + except ValueError: + pass + else: + model = KerasModel( + model.get_layer(name="image").input, # type: ignore + model.get_layer(name="dense2").output, # type: ignore + ) + + model.make_predict_function() return model - def get(self, model_category: str) -> Union[Predictor, AnyModel]: - if model_category not in self._loaded: - raise ValueError(f'Model "{model_category}" not previously loaded with "load_model(..)"') - return self._loaded[model_category] + def _load_serving_model(self, model_category, model_path, device=''): + from ocrd_utils import tf_disable_interactive_logs + tf_disable_interactive_logs() + import tensorflow as tf + + self._configure_tf_device(model_category, device=device) + model = tf.saved_model.load(model_path) + model.predict_on_batch = model.serve + model.input_shape = tuple(model.signatures.get('serving_default').inputs[0].shape) + + return model + + def _load_onnx_model(self, model_category, model_path, device=''): + import onnxruntime as ort + import numpy as np + + providers = ort.get_available_providers() + if device: + if ':' in device: + for spec in device.split(','): + cat, dev = spec.split(':') + if fnmatchcase(model_category, cat): + device = dev + break + if device == 'CPU': + gpu = -1 + else: + assert device.startswith('GPU') + gpu = int(device[3:] or "0") + else: + gpu = 0 # try first allowable + # configure and prioritise + if 'CUDAExecutionProvider' in providers: + providers.remove('CUDAExecutionProvider') + if gpu >= 0: + providers = [('CUDAExecutionProvider', { + 'device_id': gpu, + # 'arena_extend_strategy': 'kNextPowerOfTwo', + 'gpu_mem_limit': MODEL_VRAM_LIMITS[model_category] * 1024 * 1024, + # 'cudnn_conv_algo_search': 'EXHAUSTIVE', + # 'do_copy_in_default_stream': True, + # ... + })] + providers + if 'TensorrtExecutionProvider' in providers: + providers.remove('TensorrtExecutionProvider') + if gpu >= 0: + providers = [('TensorrtExecutionProvider', { + 'device_id': gpu, + 'trt_max_workspace_size': MODEL_VRAM_LIMITS[model_category] * 1024 * 1024, + # 'trt_fp16_enable': True, + # 'trt_engine_cache_enable': True, + # 'trt_timing_cache_enable': True, + # ... + })] + providers + model = ort.InferenceSession( + model_path, + providers=providers) + # FIXME: notify about selected provider/device + input_name = model.get_inputs()[0].name + output_name = model.get_outputs()[0].name + def predict_onnx(inputs): + # models expect data_type() == 'tensor(float)', but np.float16 is 'tensor(float16)' + # FIXME: do this dynamically (but how to convert .type to np.dtype?) + inputs = inputs.astype(np.float32) + return model.run( + [output_name], {input_name: inputs})[0] + model.predict_on_batch = predict_onnx + model.input_shape = model.get_inputs()[0].shape + + return model def _load_ocr_model(self, variant: str, device: str = "") -> AnyModel: """ From c79b73dcc8f31e8c27655e165a61413aca5fedb0 Mon Sep 17 00:00:00 2001 From: Robert Sachunsky Date: Tue, 2 Jun 2026 20:26:42 +0200 Subject: [PATCH 67/77] =?UTF-8?q?cnn-rnn-ocr:=20move=20CTC=20decoder=20and?= =?UTF-8?q?=20string=20decoder=20to=20inference=20model=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ModelZoo: drop `num_to_char` and `characters` model types, also drop `_load_characters()` and `_load_num_to_char()` loaders - `ModelZoo.load_models()`: use Predictor for `ocr` models, too - `ModelZoo.load_model()`: delegate runtime/inference conversion of OCR models to `eynollah.training.models.cnn_rnn_ocr_model4inference` - `training.models`: add (purely functional) Keras layer `CTCDecoder` for inference on top of softmax output, but using TF backend function instead of (broken) `Keras.backend.ctc_decode()`, while switching to beam search (instead of greedy) and also returning decoded path probability - `training.models.cnn_rnn_ocr_model()` w/ `inference=True`: * add kwarg `characters_txt_file` for file path of character set * configure secondary tensor path on OCR graph for binarized input (additional input `image_bin`, averaging softmax outputs) * use new `CTCDecoder` layer and inverse `StringLookup` layer to decode from softmax output to tf.string; so inference models now have 2 inputs (RGB, binarized) and 2 outputs (text, prob) * since `np.dtype=object` cannot be handled by SharedMemory (as needed by Predictor queues), also replace tf.string by tf.uint8 arrays * use this for `training convert` for OCR models w/ `--rebuild` - `training.models.cnn_rnn_ocr_model4inference`: * new function which does the same but loads an existing OCR model in training configuration (i.e. without prior `inference=True`) * use this for `training convert` for OCR models w/o `--rebuild` --- src/eynollah/model_zoo/default_specs.py | 16 ----- src/eynollah/model_zoo/model_zoo.py | 42 ++--------- src/eynollah/training/convert.py | 12 ++-- src/eynollah/training/models.py | 93 ++++++++++++++++++++++++- 4 files changed, 100 insertions(+), 63 deletions(-) diff --git a/src/eynollah/model_zoo/default_specs.py b/src/eynollah/model_zoo/default_specs.py index dc725e4..170d944 100644 --- a/src/eynollah/model_zoo/default_specs.py +++ b/src/eynollah/model_zoo/default_specs.py @@ -208,22 +208,6 @@ DEFAULT_MODEL_SPECS = EynollahModelSpecSet([ type='Keras', ), - EynollahModelSpec( - category="num_to_char", - variant='', - filename="characters_org.txt", - dist_url=dist_url("ocr"), - type='decoder', - ), - - EynollahModelSpec( - category="characters", - variant='', - filename="characters_org.txt", - dist_url=dist_url("ocr"), - type='List[str]', - ), - EynollahModelSpec( category="ocr", variant='tr', diff --git a/src/eynollah/model_zoo/model_zoo.py b/src/eynollah/model_zoo/model_zoo.py index 0a68203..e7d21aa 100644 --- a/src/eynollah/model_zoo/model_zoo.py +++ b/src/eynollah/model_zoo/model_zoo.py @@ -123,12 +123,8 @@ class EynollahModelZoo: model_category = model_category[:-8] load_kwargs["patched"] = True - if model_category == 'ocr': + if model_category == 'ocr' and model_variant == 'tr': model = self._load_ocr_model(variant=model_variant, device=device) - elif model_category == 'num_to_char': - model = self._load_num_to_char() - elif model_category == 'characters': - model = self._load_characters() elif model_category == 'trocr_processor': from transformers import TrOCRProcessor model_path = self.model_path(model_category, model_variant) @@ -232,9 +228,12 @@ class EynollahModelZoo: from tensorflow.keras.models import load_model from tensorflow.keras.models import Model as KerasModel + from ..training.models import cnn_rnn_ocr_model4inference + self._configure_tf_device(model_category, device=device) model = load_model(model_path, compile=False) + assert isinstance(model, KerasModel) # from ..patch_encoder import ( # wrap_layout_model_patched, @@ -249,15 +248,7 @@ class EynollahModelZoo: if model_category == 'ocr': # cnn-rnn-ocr task model may not be in inference mode, yet - try: - model.get_layer(name='ctc_loss') - except ValueError: - pass - else: - model = KerasModel( - model.get_layer(name="image").input, # type: ignore - model.get_layer(name="dense2").output, # type: ignore - ) + model = cnn_rnn_ocr_model4inference(model, model_path) model.make_predict_function() @@ -369,29 +360,6 @@ class EynollahModelZoo: return self.load_model('ocr', model_variant=variant, device=device) - def _load_characters(self) -> List[str]: - """ - Load encoding for OCR - """ - with open(self.model_path('num_to_char'), "r") as config_file: - return json.load(config_file) - - def _load_num_to_char(self) -> 'StringLookup': - """ - Load decoder for OCR - """ - os.environ['TF_USE_LEGACY_KERAS'] = '1' # avoid Keras 3 after TF 2.15 - from ocrd_utils import tf_disable_interactive_logs - tf_disable_interactive_logs() - - from tensorflow.keras.layers import StringLookup - - characters = self._load_characters() - # Mapping characters to integers. - char_to_num = StringLookup(vocabulary=characters, mask_token=None) - # Mapping integers back to original characters. - return StringLookup(vocabulary=char_to_num.get_vocabulary(), mask_token=None, invert=True) - def __str__(self): return tabulate( [ diff --git a/src/eynollah/training/convert.py b/src/eynollah/training/convert.py index dd4271f..140079e 100644 --- a/src/eynollah/training/convert.py +++ b/src/eynollah/training/convert.py @@ -2,6 +2,7 @@ import os from pathlib import Path from shutil import copy2 import logging +import json import click @@ -74,18 +75,13 @@ def convert_cli(rebuild, format_, in_, out): model = get_model(config, logging.root) model.load_weights(model_path).assert_existing_objects_matched().expect_partial() else: + from .models import cnn_rnn_ocr_model4inference + model = load_model(model_path, compile=False) if isinstance(model, KerasModel): # cnn-rnn-ocr task deviates between training and inference - try: - model.get_layer(name='ctc_loss') - except ValueError: - pass - else: - model = KerasModel( - model.get_layer(name='image').input, - model.get_layer(name='dense2').output) + model = cnn_rnn_ocr_model4inference(model, model_path) if format_ in ["hdf5", "keras", "tf"]: kwargs = {"save_format": {"hdf5": "h5"}.get(format_, format_)} diff --git a/src/eynollah/training/models.py b/src/eynollah/training/models.py index 83058ee..528c848 100644 --- a/src/eynollah/training/models.py +++ b/src/eynollah/training/models.py @@ -1,4 +1,5 @@ import os +import json os.environ['TF_USE_LEGACY_KERAS'] = '1' # avoid Keras 3 after TF 2.15 import tensorflow as tf @@ -23,6 +24,7 @@ from tensorflow.keras.layers import ( Reshape, UpSampling2D, ZeroPadding2D, + StringLookup, add, concatenate ) @@ -57,6 +59,50 @@ class CTCLayer(Layer): # At test time, just return the computed predictions. return y_pred + +class CTCDecoder(Layer): + def call(self, inputs): + n_samples = tf.shape(inputs)[0] + n_steps = inputs.shape[1] + n_classes = inputs.shape[2] + lengths = tf.ones(n_samples, dtype=tf.int32) * n_steps + ## Keras beam search seems to mess with double letters + ## but Keras greedy sometimes removes arbitrary letters + # outputs, logits = tf.keras.backend.ctc_decode(inputs, + # lengths, + # beam_width=20 + # greedy=False, # True, + # # backend does not allow these kwargs + # #merge_repeated=False, + # #mask_index=inputs.shape[2]-1, + # ) + # tf.nn.ctc_*_decoder (in contrast to tf.keras.backend.ctc_decode) + # needs logits instead of probs and time-major (batch 2nd dim) + inputs = tf.math.log( + tf.transpose(inputs, perm=[1, 0, 2]) + tf.keras.backend.epsilon() + ) + # tf.nn.ctc_greedy_decoder() is not as precise + # tf.compat.v1.nn.ctc_beam_search_decoder() also needs merge_repeated=False + decoded, logits = tf.nn.ctc_beam_search_decoder( + inputs, + lengths, + beam_width=10, + top_paths=2, + ) + # get top path for all sequences in batch + decoded = decoded[0] + logits = logits[:, 0] - logits[:, 1] + # convert to dense + outputs = tf.SparseTensor(decoded.indices, decoded.values, + (n_samples, n_steps)) + outputs = tf.sparse.to_dense(sp_input=outputs, default_value=-1) + # # drop non-tokens (-1) and OOV (0) + # result = [] + # for output in outputs: + # result.append(tf.gather(output, tf.where(output > 0))) + # outputs = tf.stack(result) + probs = tf.exp(-logits) + return outputs, probs def mlp(x, hidden_units, dropout_rate): for units in hidden_units: @@ -422,7 +468,7 @@ def machine_based_reading_order_model(n_classes,input_height=224,input_width=224 return model -def cnn_rnn_ocr_model(image_height=None, image_width=None, n_classes=None, max_len=None, inference=False): +def cnn_rnn_ocr_model(image_height=None, image_width=None, n_classes=None, max_len=None, inference=False, characters_txt_file=None): inputs = Input(shape=(image_height, image_width, 3), name="image") labels = Input(name="label", shape=(None,)) @@ -492,13 +538,56 @@ def cnn_rnn_ocr_model(image_height=None, image_width=None, n_classes=None, max_l out = Dense(n_classes, activation="softmax", name="dense2")(out) if inference: - return Model(inputs, out) + # add second path for binarization + inputs_bin = Input(shape=(image_height, image_width, 3), name="image_bin") + out_bin = Model(inputs, out)(inputs_bin) + # ensemble raw results + out = 0.5 * (out + out_bin) + # get tf.string batch + out, prob = CTCDecoder()(out) + # decode int to str + with open(characters_txt_file, "r") as voc_file: + voc = json.load(voc_file) + char2num = StringLookup(vocabulary=voc) + voc = char2num.get_vocabulary() + num2char = StringLookup(vocabulary=voc, invert=True) + output = num2char(out) + # avoid output tf.dtype=string → np.dtype=object (which cannot be shm-ed) + output = tf.io.decode_raw(output, tf.uint8, fixed_length=max(map(len, voc))) + + return Model((inputs, inputs_bin), (output, prob)) # Add CTC layer for calculating CTC loss at each step. out = CTCLayer(name="ctc_loss")(labels, out) return Model((inputs, labels), out) +def cnn_rnn_ocr_model4inference(model, model_path): + """convert trained cnn-rnn-ocr model to inference model post-hoc""" + try: + model.get_layer(name='ctc_loss') + except ValueError: + # likely already converted + return model + else: + inputs = model.get_layer(name='image').input + output = model.get_layer(name='dense2').output + inputs_bin = Input(inputs.shape[1:], name='image_bin') + output_bin = Model(inputs, output)(inputs_bin) + output = 0.5 * (output + output_bin) + output, prob = CTCDecoder()(output) + with open(model_path / "characters_org.txt", "r") as voc_file: + voc = json.load(voc_file) + char2num = StringLookup(vocabulary=voc) + voc = char2num.get_vocabulary() + num2char = StringLookup(vocabulary=voc, invert=True) + output = num2char(output) + # avoid output tf.dtype=string → np.dtype=object (which cannot be shm-ed) + output = tf.io.decode_raw(output, tf.uint8, fixed_length=max(map(len, voc))) + inputs = (inputs, inputs_bin) + outputs = (output, prob) + return Model(inputs, outputs) + def get_model(config, logger): from sacred.config import create_captured_function From a391ee24e68b9dbced39efe50de1a70a5dea115d Mon Sep 17 00:00:00 2001 From: Robert Sachunsky Date: Tue, 2 Jun 2026 21:18:22 +0200 Subject: [PATCH 68/77] Predictor: handle multi-input and/or multi-output cases --- src/eynollah/predictor.py | 61 ++++++++++++++++++++++++++++++++------- 1 file changed, 51 insertions(+), 10 deletions(-) diff --git a/src/eynollah/predictor.py b/src/eynollah/predictor.py index 3c6890e..141d3f0 100644 --- a/src/eynollah/predictor.py +++ b/src/eynollah/predictor.py @@ -1,5 +1,5 @@ from contextlib import ExitStack -from typing import List, Dict +from typing import List, Dict, Tuple, Union import logging import logging.handlers import multiprocessing as mp @@ -8,6 +8,7 @@ import numpy as np from .utils.shm import share_ndarray, ndarray_shared QSIZE = 200 +ArrayT = Union[np.ndarray, Tuple[np.ndarray]] class Predictor(mp.context.SpawnProcess): @@ -40,10 +41,10 @@ class Predictor(mp.context.SpawnProcess): def input_shape(self): return self({}) - def predict(self, data: dict, verbose=0): + def predict(self, data: ArrayT, verbose=0) -> ArrayT: return self(data) - def __call__(self, data: dict): + def __call__(self, data: Union[ArrayT, Dict]) -> Union[ArrayT, Tuple]: # unusable as per python/cpython#79967 #with self.jobid.get_lock(): # would work, but not public: @@ -55,7 +56,15 @@ class Predictor(mp.context.SpawnProcess): self.taskq.put((jobid, data)) #self.logger.debug("sent shape query task '%d' for model '%s'", jobid, self.name) return self.result(jobid) - with share_ndarray(data) as shared_data: + with ExitStack() as stack: + if isinstance(data, tuple): + # multi-input + shared_data = [] + for data0 in data: + shared_data.append(stack.enter_context(share_ndarray(data0))) + shared_data = tuple(shared_data) + else: + shared_data = stack.enter_context(share_ndarray(data)) self.taskq.put((jobid, shared_data)) #self.logger.debug("sent prediction task '%d' for model '%s': %s", jobid, self.name, shared_data) return self.result(jobid) @@ -67,6 +76,14 @@ class Predictor(mp.context.SpawnProcess): result = self.results.pop(jobid) if isinstance(result, Exception): raise Exception(f"predictor {self.name} failed for {jobid}") from result + elif isinstance(result, tuple) and isinstance(result[0], dict): + # multi-output + result1 = [] + for result0 in result: + with ndarray_shared(result0) as shared_result0: + result1.append(np.copy(shared_result0)) + result = result1 + self.closable.append(jobid) elif isinstance(result, dict): with ndarray_shared(result) as shared_result: result = np.copy(shared_result) @@ -111,6 +128,7 @@ class Predictor(mp.context.SpawnProcess): "binarization": 4, "enhancement": 4, "reading_order": 4, + "ocr": 8, # medium size (672x672x3)... "textline": 2, # large models... @@ -126,8 +144,13 @@ class Predictor(mp.context.SpawnProcess): self.resultq.put((jobid, result)) #self.logger.debug("sent result for '%d': %s", jobid, result) else: + if isinstance(shared_data, tuple): + multi_input = True + batch_size = shared_data[0]['shape'][0] + else: + multi_input = False + batch_size = shared_data['shape'][0] tasks = [(jobid, shared_data)] - batch_size = shared_data['shape'][0] while (not self.taskq.empty() and # climb to target batch size batch_size * len(tasks) < REBATCH_SIZE): @@ -136,7 +159,7 @@ class Predictor(mp.context.SpawnProcess): # add to our batch tasks.append((jobid0, shared_data0)) else: - # immediately anser + # immediately answer self.resultq.put((jobid0, self.model.input_shape)) if len(tasks) > 1: self.logger.debug("rebatching %d '%s' tasks of batch size %d", @@ -147,12 +170,26 @@ class Predictor(mp.context.SpawnProcess): for jobid, shared_data in tasks: #self.logger.debug("predicting '%d' with model '%s': %s", jobid, self.name, shared_data) jobs.append(jobid) - data.append(stack.enter_context(ndarray_shared(shared_data))) - data = np.concatenate(data) + if multi_input: + data.append(tuple(stack.enter_context(ndarray_shared(shared_data0)) + for shared_data0 in shared_data)) + else: + data.append(stack.enter_context(ndarray_shared(shared_data))) + if multi_input: + data = tuple(np.concatenate(data0) + for data0 in zip(*data)) + else: + data = np.concatenate(data) #result = self.model.predict(data, verbose=0) # faster, less VRAM result = self.model.predict_on_batch(data) - results = np.split(result, len(jobs)) + if isinstance(result, tuple): + multi_output = True + results = zip(*(np.split(result0, len(jobs)) + for result0 in result)) + else: + multi_output = False + results = np.split(result, len(jobs)) #self.logger.debug("sharing result array for '%d'", jobid) with ExitStack() as stack: for jobid, result in zip(jobs, results): @@ -160,7 +197,11 @@ class Predictor(mp.context.SpawnProcess): # but don't want to wait either, so track closing # context per job, and wait for closable signal # from client - result = stack.enter_context(share_ndarray(result)) + if multi_output: + result = tuple(stack.enter_context(share_ndarray(result0)) + for result0 in result) + else: + result = stack.enter_context(share_ndarray(result)) closing[jobid] = stack.pop_all() self.resultq.put((jobid, result)) #self.logger.debug("sent result for '%d': %s", jobid, result) From 8ffc4ed8d377fea54174cbc606ce0df38b46bf8b Mon Sep 17 00:00:00 2001 From: Robert Sachunsky Date: Tue, 2 Jun 2026 21:20:06 +0200 Subject: [PATCH 69/77] =?UTF-8?q?Eynollah=5Focr:=20adapt=20to=20inference?= =?UTF-8?q?=20model,=20improve=20and=20simplify=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - drop `end_character` mechanics and `characters` model type for decoding output probability (not needed) - drop `decode_batch_predictions()` and `num_to_char` model type (part of inference model) - drop roughshot confidence estimation calculation (returned precisely by inference model) - adapt model prediction to inference model: just omit zeros, map to bytes, filter OOV tokens and decode UTF-8 to str - if no binarization input was provided, then compute it on the fly using `binarization` model - also apply `min_conf_value_of_textline_text` (as for TrOCR) - batching over entire page instead of region-wise (which underfilled batches) - simplify and avoid copied redundant code - rename `extracted_conf_value_merged` → `extracted_confs_merged` - move `batched()` from `utils.utils_ocr` to `utils` - drop `utils_ocr.distortion_free_resize()` (not needed) - simplify `utils_ocr.break_curved_line_into_small_pieces_and_then_merge()` - drop `utils_ocr.return_textline_contour_with_added_box_coordinate()` and `utils_ocr.return_rnn_cnn_ocr_of_given_textlines()` (not needed) --- src/eynollah/eynollah_ocr.py | 512 ++++++++++---------------------- src/eynollah/utils/__init__.py | 6 + src/eynollah/utils/utils_ocr.py | 319 +++----------------- 3 files changed, 206 insertions(+), 631 deletions(-) diff --git a/src/eynollah/eynollah_ocr.py b/src/eynollah/eynollah_ocr.py index b94853b..40cbeaa 100644 --- a/src/eynollah/eynollah_ocr.py +++ b/src/eynollah/eynollah_ocr.py @@ -19,27 +19,29 @@ from ocrd_utils import polygon_from_points, xywh_from_polygon from .eynollah import Eynollah from .model_zoo import EynollahModelZoo -from .utils import is_image_filename +from .utils import ( + is_image_filename, + batched, + pairwise, +) from .utils.font import get_font from .utils.xml import etree_namespace_for_element_tag from .utils.resize import resize_image from .utils.utils_ocr import ( break_curved_line_into_small_pieces_and_then_merge, - decode_batch_predictions, fit_text_single_line, get_contours_and_bounding_boxes, get_orientation_moments, preprocess_and_resize_image_for_ocrcnn_model, return_textlines_split_if_needed, rotate_image_with_padding, - batched, ) # TODO: refine typing @dataclass class EynollahOcrResult: extracted_texts_merged: List - extracted_conf_value_merged: Optional[List] + extracted_confs_merged: Optional[List] cropped_lines_region_indexer: List total_bb_coordinates:List @@ -73,10 +75,8 @@ class Eynollah_ocr(Eynollah): device=device) else: self.model_zoo.load_models('ocr', - 'num_to_char', - 'characters', + 'binarization', device=device) - self.end_character = len(self.model_zoo.get('characters')) + 2 @property def device(self): @@ -95,8 +95,6 @@ class Eynollah_ocr(Eynollah): cropped_lines = [] cropped_lines_region_indexer = [] cropped_lines_meging_indexing = [] - extracted_texts = [] - extracted_confs = [] for n_region, region in enumerate(page_tree.getroot().iter('{%s}TextRegion' % page_ns)): for n_line, line in enumerate(region.iter('{%s}TextLine' % page_ns)): @@ -139,7 +137,8 @@ class Eynollah_ocr(Eynollah): cropped_lines.append(img_crop) cropped_lines_meging_indexing.append(0) - + extracted_texts = [] + extracted_confs = [] self.logger.debug("processing %d lines for %d regions", len(cropped_lines), len(set(cropped_lines_region_indexer))) for imgs in batched(cropped_lines, self.b_s): @@ -157,6 +156,10 @@ class Eynollah_ocr(Eynollah): conf = output.sequences_scores.exp().clamp(0.0, 1.0).tolist() else: conf = [1.0] * len(output.sequences) + if conf < self.min_conf_value_of_textline_text: + extracted_confs.extend(0) + extracted_texts.extend("") + continue text = self.model_zoo.get('trocr_processor').batch_decode( output.sequences, skip_special_tokens=True, @@ -179,7 +182,7 @@ class Eynollah_ocr(Eynollah): return EynollahOcrResult( extracted_texts_merged=extracted_texts_merged, - extracted_conf_value_merged=extracted_confs_merged, + extracted_confs_merged=extracted_confs_merged, cropped_lines_region_indexer=cropped_lines_region_indexer, total_bb_coordinates=total_bb_coordinates, ) @@ -196,362 +199,163 @@ class Eynollah_ocr(Eynollah): ) -> EynollahOcrResult: total_bb_coordinates = [] - - cropped_lines = [] - img_crop_bin = None - imgs_bin = None - imgs_bin_ver_flipped = None + cropped_lines_rgb = [] cropped_lines_bin = [] cropped_lines_ver_index = [] cropped_lines_region_indexer = [] cropped_lines_meging_indexing = [] - - indexer_text_region = 0 - for nn in page_tree.getroot().iter(f'{{{page_ns}}}TextRegion'): - try: - type_textregion = nn.attrib['type'] - except: - type_textregion = 'paragraph' - for child_textregion in nn: - if child_textregion.tag.endswith("TextLine"): - for child_textlines in child_textregion: - if child_textlines.tag.endswith("Coords"): - cropped_lines_region_indexer.append(indexer_text_region) - p_h=child_textlines.attrib['points'].split(' ') - textline_coords = np.array( [ [int(x.split(',')[0]), - int(x.split(',')[1]) ] - for x in p_h] ) - - x,y,w,h = cv2.boundingRect(textline_coords) - - angle_radians = math.atan2(h, w) - # Convert to degrees - angle_degrees = math.degrees(angle_radians) - if type_textregion=='drop-capital': - angle_degrees = 0 - - total_bb_coordinates.append([x,y,w,h]) - - w_scaled = w * image_height/float(h) - - img_poly_on_img = np.copy(img) - if img_bin: - img_poly_on_img_bin = np.copy(img_bin) - img_crop_bin = img_poly_on_img_bin[y:y+h, x:x+w, :] - - mask_poly = np.zeros(img.shape) - mask_poly = cv2.fillPoly(mask_poly, pts=[textline_coords], color=(1, 1, 1)) - - - mask_poly = mask_poly[y:y+h, x:x+w, :] - img_crop = img_poly_on_img[y:y+h, x:x+w, :] - - # print(file_name, angle_degrees, w*h, - # mask_poly[:,:,0].sum(), - # mask_poly[:,:,0].sum() /float(w*h) , - # 'didi') - - if angle_degrees > 3: - better_des_slope = get_orientation_moments(textline_coords) - - img_crop = rotate_image_with_padding(img_crop, better_des_slope) - if img_bin: - img_crop_bin = rotate_image_with_padding(img_crop_bin, better_des_slope) - - mask_poly = rotate_image_with_padding(mask_poly, better_des_slope) - mask_poly = mask_poly.astype('uint8') - - #new bounding box - x_n, y_n, w_n, h_n = get_contours_and_bounding_boxes(mask_poly[:,:,0]) - - mask_poly = mask_poly[y_n:y_n+h_n, x_n:x_n+w_n, :] - img_crop = img_crop[y_n:y_n+h_n, x_n:x_n+w_n, :] - - if not self.do_not_mask_with_textline_contour: - img_crop[mask_poly==0] = 255 - if img_bin: - img_crop_bin = img_crop_bin[y_n:y_n+h_n, x_n:x_n+w_n, :] - if not self.do_not_mask_with_textline_contour: - img_crop_bin[mask_poly==0] = 255 - - if mask_poly[:,:,0].sum() /float(w_n*h_n) < 0.50 and w_scaled > 90: - if img_bin: - img_crop, img_crop_bin = \ - break_curved_line_into_small_pieces_and_then_merge( - img_crop, mask_poly, img_crop_bin) - else: - img_crop, _ = \ - break_curved_line_into_small_pieces_and_then_merge( - img_crop, mask_poly) - else: - better_des_slope = 0 - if not self.do_not_mask_with_textline_contour: - img_crop[mask_poly==0] = 255 - if img_bin: - if not self.do_not_mask_with_textline_contour: - img_crop_bin[mask_poly==0] = 255 - if type_textregion=='drop-capital': - pass - else: - if mask_poly[:,:,0].sum() /float(w*h) < 0.50 and w_scaled > 90: - if img_bin: - img_crop, img_crop_bin = \ - break_curved_line_into_small_pieces_and_then_merge( - img_crop, mask_poly, img_crop_bin) - else: - img_crop, _ = \ - break_curved_line_into_small_pieces_and_then_merge( - img_crop, mask_poly) - - if w_scaled < 750:#1.5*image_width: - img_fin = preprocess_and_resize_image_for_ocrcnn_model( - img_crop, image_height, image_width) - cropped_lines.append(img_fin) - if abs(better_des_slope) > 45: - cropped_lines_ver_index.append(1) - else: - cropped_lines_ver_index.append(0) - - cropped_lines_meging_indexing.append(0) - if img_bin: - img_fin = preprocess_and_resize_image_for_ocrcnn_model( - img_crop_bin, image_height, image_width) - cropped_lines_bin.append(img_fin) - else: - splited_images, splited_images_bin = return_textlines_split_if_needed( - img_crop, img_crop_bin if img_bin else None) - if splited_images: - img_fin = preprocess_and_resize_image_for_ocrcnn_model( - splited_images[0], image_height, image_width) - cropped_lines.append(img_fin) - cropped_lines_meging_indexing.append(1) - - if abs(better_des_slope) > 45: - cropped_lines_ver_index.append(1) - else: - cropped_lines_ver_index.append(0) - - img_fin = preprocess_and_resize_image_for_ocrcnn_model( - splited_images[1], image_height, image_width) - - cropped_lines.append(img_fin) - cropped_lines_meging_indexing.append(-1) - - if abs(better_des_slope) > 45: - cropped_lines_ver_index.append(1) - else: - cropped_lines_ver_index.append(0) - - if img_bin: - img_fin = preprocess_and_resize_image_for_ocrcnn_model( - splited_images_bin[0], image_height, image_width) - cropped_lines_bin.append(img_fin) - img_fin = preprocess_and_resize_image_for_ocrcnn_model( - splited_images_bin[1], image_height, image_width) - cropped_lines_bin.append(img_fin) - - else: - img_fin = preprocess_and_resize_image_for_ocrcnn_model( - img_crop, image_height, image_width) - cropped_lines.append(img_fin) - cropped_lines_meging_indexing.append(0) - - if abs(better_des_slope) > 45: - cropped_lines_ver_index.append(1) - else: - cropped_lines_ver_index.append(0) - - if img_bin: - img_fin = preprocess_and_resize_image_for_ocrcnn_model( - img_crop_bin, image_height, image_width) - cropped_lines_bin.append(img_fin) - + img_rgb = img # cosmetic + if img_bin is None: + # run ad-hoc binarization + self.logger.info("running binarization for ensemble input") + img_bin = self.do_prediction(True, img, self.model_zoo.get("binarization"), + n_batch_inference=5) + img_bin = np.repeat(img_bin[:, :, np.newaxis], 3, axis=2) + img_bin = 255 * (img_bin == 0).astype(np.uint8) + + for n_region, region in enumerate(page_tree.getroot().iter('{%s}TextRegion' % page_ns)): + type_textregion = region.attrib.get('type', 'paragraph') + for n_line, line in enumerate(region.iter('{%s}TextLine' % page_ns)): + cropped_lines_region_indexer.append(n_region) + + coords = line.find('{%s}Coords' % page_ns) + if coords is None: + self.logger.warning("region '%s' line '%s' has no Coords", region.attrib['id'], line.attrib['id']) + continue + poly = np.array(polygon_from_points(coords.attrib['points'])).astype(int) + cont = poly[:, np.newaxis] + xywh = xywh_from_polygon(poly) + x, y, w, h = xywh['x'], xywh['y'], xywh['w'], xywh['h'] + + angle_radians = math.atan2(h, w) + angle_degrees = math.degrees(angle_radians) + if type_textregion=='drop-capital': + angle_degrees = 0 + + total_bb_coordinates.append([x, y, w, h]) + + w_scaled = w * image_height / float(h) + + img_crop_rgb = img_rgb[y: y + h, x: x + w] + img_crop_bin = img_bin[y: y + h, x: x + w] + + mask_poly = np.zeros(img_crop_rgb.shape[:2], dtype=np.uint8) + mask_poly = cv2.fillPoly(mask_poly, pts=[cont - [x, y]], color=1) + + if angle_degrees > 3: + better_des_slope = get_orientation_moments(cont) + img_crop_rgb = rotate_image_with_padding(img_crop_rgb, better_des_slope) + img_crop_bin = rotate_image_with_padding(img_crop_bin, better_des_slope) + mask_poly = rotate_image_with_padding(mask_poly, better_des_slope) + # get new bounding box + x_n, y_n, w_n, h_n = get_contours_and_bounding_boxes(mask_poly) + img_crop_rgb = img_crop_rgb[y_n: y_n + h_n, x_n: x_n + w_n] + img_crop_bin = img_crop_bin[y_n: y_n + h_n, x_n: x_n + w_n] + mask_poly = mask_poly[y_n: y_n + h_n, x_n: x_n + w_n] + else: + better_des_slope = 0 + + if not self.do_not_mask_with_textline_contour: + img_crop_rgb[mask_poly == 0] = 255 # FIXME: or median color? + img_crop_bin[mask_poly == 0] = 255 + + if (type_textregion !='drop-capital' and + mask_poly.sum() < 0.50 * mask_poly.size and + w_scaled > 90): + + img_crop_rgb, img_crop_bin = \ + break_curved_line_into_small_pieces_and_then_merge( + img_crop_rgb, img_crop_bin, mask_poly) + + if w_scaled < 750:#1.5*image_width: + img_crop_split_rgb = img_crop_split_bin = None + else: + img_crop_split_rgb, img_crop_split_bin = return_textlines_split_if_needed( + img_crop_rgb, img_crop_bin) + if img_crop_split_rgb: + cropped_lines_rgb.extend(img_crop_split_rgb) + cropped_lines_bin.extend(img_crop_split_bin) + if abs(better_des_slope) > 45: + cropped_lines_ver_index.append(1) + cropped_lines_ver_index.append(1) + else: + cropped_lines_ver_index.append(0) + cropped_lines_ver_index.append(0) + cropped_lines_meging_indexing.append(1) + cropped_lines_meging_indexing.append(-1) + else: + cropped_lines_rgb.append(img_crop_rgb) + cropped_lines_bin.append(img_crop_bin) + if abs(better_des_slope) > 45: + cropped_lines_ver_index.append(1) + else: + cropped_lines_ver_index.append(0) + cropped_lines_meging_indexing.append(0) + + cropped_lines_rgb = [preprocess_and_resize_image_for_ocrcnn_model(img, image_height, image_width) + for img in cropped_lines_rgb] + cropped_lines_bin = [preprocess_and_resize_image_for_ocrcnn_model(img, image_height, image_width) + for img in cropped_lines_bin] - indexer_text_region = indexer_text_region +1 - extracted_texts = [] - extracted_conf_value = [] + extracted_confs = [] + self.logger.debug("processing %d lines for %d regions", + len(cropped_lines_rgb), len(set(cropped_lines_region_indexer))) + cropped_lines = zip(cropped_lines_rgb, cropped_lines_bin, cropped_lines_ver_index) + for batch in batched(cropped_lines, self.b_s): + imgs_rgb, imgs_bin, ver_index = zip(*batch) + ver_index = np.array(ver_index) + imgs_rgb = np.stack(imgs_rgb) + imgs_bin = np.stack(imgs_bin) + imgs_rgb_ver = imgs_rgb[ver_index > 0, ::-1, ::-1] + imgs_bin_ver = imgs_bin[ver_index > 0, ::-1, ::-1] - n_iterations = math.ceil(len(cropped_lines) / self.b_s) - - # FIXME: copy pasta - for i in range(n_iterations): - if i==(n_iterations-1): - n_start = i*self.b_s - imgs = cropped_lines[n_start:] - imgs = np.array(imgs) - imgs = imgs.reshape(imgs.shape[0], image_height, image_width, 3) - - ver_imgs = np.array( cropped_lines_ver_index[n_start:] ) - indices_ver = np.where(ver_imgs == 1)[0] - - #print(indices_ver, 'indices_ver') - if len(indices_ver)>0: - imgs_ver_flipped = imgs[indices_ver, : ,: ,:] - imgs_ver_flipped = imgs_ver_flipped[:,::-1,::-1,:] - #print(imgs_ver_flipped, 'imgs_ver_flipped') - - else: - imgs_ver_flipped = None - - if img_bin: - imgs_bin = cropped_lines_bin[n_start:] - imgs_bin = np.array(imgs_bin) - imgs_bin = imgs_bin.reshape(imgs_bin.shape[0], image_height, image_width, 3) - - if len(indices_ver)>0: - imgs_bin_ver_flipped = imgs_bin[indices_ver, : ,: ,:] - imgs_bin_ver_flipped = imgs_bin_ver_flipped[:,::-1,::-1,:] - #print(imgs_ver_flipped, 'imgs_ver_flipped') - - else: - imgs_bin_ver_flipped = None - else: - n_start = i*self.b_s - n_end = (i+1)*self.b_s - imgs = cropped_lines[n_start:n_end] - imgs = np.array(imgs).reshape(self.b_s, image_height, image_width, 3) - - ver_imgs = np.array( cropped_lines_ver_index[n_start:n_end] ) - indices_ver = np.where(ver_imgs == 1)[0] - #print(indices_ver, 'indices_ver') - - if len(indices_ver)>0: - imgs_ver_flipped = imgs[indices_ver, : ,: ,:] - imgs_ver_flipped = imgs_ver_flipped[:,::-1,::-1,:] - #print(imgs_ver_flipped, 'imgs_ver_flipped') - else: - imgs_ver_flipped = None - - - if img_bin: - imgs_bin = cropped_lines_bin[n_start:n_end] - imgs_bin = np.array(imgs_bin).reshape(self.b_s, image_height, image_width, 3) - - - if len(indices_ver)>0: - imgs_bin_ver_flipped = imgs_bin[indices_ver, : ,: ,:] - imgs_bin_ver_flipped = imgs_bin_ver_flipped[:,::-1,::-1,:] - #print(imgs_ver_flipped, 'imgs_ver_flipped') - else: - imgs_bin_ver_flipped = None - - - self.logger.debug("processing next %d lines", len(imgs)) - preds = self.model_zoo.get('ocr').predict(imgs, verbose=0) + # inference model now yields (char-bytes, line-prob) instead of vocidx-softmax + # (so ctc_decode and inverse StringLookup are included) + # also, the model now expects a secondary binary input image + preds, probs = self.model_zoo.get('ocr').predict((imgs_rgb, imgs_bin), verbose=0) - if len(indices_ver)>0: - preds_flipped = self.model_zoo.get('ocr').predict(imgs_ver_flipped, verbose=0) - preds_max_fliped = np.max(preds_flipped, axis=2 ) - preds_max_args_flipped = np.argmax(preds_flipped, axis=2 ) - pred_max_not_unk_mask_bool_flipped = preds_max_args_flipped[:,:]!=self.end_character - masked_means_flipped = \ - np.sum(preds_max_fliped * pred_max_not_unk_mask_bool_flipped, axis=1) / \ - np.sum(pred_max_not_unk_mask_bool_flipped, axis=1) - masked_means_flipped[np.isnan(masked_means_flipped)] = 0 - - preds_max = np.max(preds, axis=2 ) - preds_max_args = np.argmax(preds, axis=2 ) - pred_max_not_unk_mask_bool = preds_max_args[:,:]!=self.end_character - - masked_means = \ - np.sum(preds_max * pred_max_not_unk_mask_bool, axis=1) / \ - np.sum(pred_max_not_unk_mask_bool, axis=1) - masked_means[np.isnan(masked_means)] = 0 - - masked_means_ver = masked_means[indices_ver] - #print(masked_means_ver, 'pred_max_not_unk') - - indices_where_flipped_conf_value_is_higher = \ - np.where(masked_means_flipped > masked_means_ver)[0] - - #print(indices_where_flipped_conf_value_is_higher, 'indices_where_flipped_conf_value_is_higher') - if len(indices_where_flipped_conf_value_is_higher)>0: - indices_to_be_replaced = indices_ver[indices_where_flipped_conf_value_is_higher] - preds[indices_to_be_replaced,:,:] = \ - preds_flipped[indices_where_flipped_conf_value_is_higher, :, :] + if ver_index.any(): + preds_ver, probs_ver = self.model_zoo.get('ocr').predict((imgs_rgb_ver, imgs_bin_ver), verbose=0) + flipped_ver_is_better = np.flatnonzero(probs_ver > probs[ver_index > 0]) + if len(flipped_ver_is_better): + self.logger.info("%d skewed lines perform better when flipped", len(flipped_ver_is_better)) + preds[ver_index > 0][flipped_ver_is_better] = preds_ver[flipped_ver_is_better] + probs[ver_index > 0][flipped_ver_is_better] = probs_ver[flipped_ver_is_better] - if img_bin: - preds_bin = self.model_zoo.get('ocr').predict(imgs_bin, verbose=0) - - if len(indices_ver)>0: - preds_flipped = self.model_zoo.get('ocr').predict(imgs_bin_ver_flipped, verbose=0) - preds_max_fliped = np.max(preds_flipped, axis=2 ) - preds_max_args_flipped = np.argmax(preds_flipped, axis=2 ) - pred_max_not_unk_mask_bool_flipped = preds_max_args_flipped[:,:]!=self.end_character - masked_means_flipped = \ - np.sum(preds_max_fliped * pred_max_not_unk_mask_bool_flipped, axis=1) / \ - np.sum(pred_max_not_unk_mask_bool_flipped, axis=1) - masked_means_flipped[np.isnan(masked_means_flipped)] = 0 - - preds_max = np.max(preds, axis=2 ) - preds_max_args = np.argmax(preds, axis=2 ) - pred_max_not_unk_mask_bool = preds_max_args[:,:]!=self.end_character - - masked_means = \ - np.sum(preds_max * pred_max_not_unk_mask_bool, axis=1) / \ - np.sum(pred_max_not_unk_mask_bool, axis=1) - masked_means[np.isnan(masked_means)] = 0 - - masked_means_ver = masked_means[indices_ver] - #print(masked_means_ver, 'pred_max_not_unk') - - indices_where_flipped_conf_value_is_higher = \ - np.where(masked_means_flipped > masked_means_ver)[0] - - #print(indices_where_flipped_conf_value_is_higher, 'indices_where_flipped_conf_value_is_higher') - if len(indices_where_flipped_conf_value_is_higher)>0: - indices_to_be_replaced = indices_ver[indices_where_flipped_conf_value_is_higher] - preds_bin[indices_to_be_replaced,:,:] = \ - preds_flipped[indices_where_flipped_conf_value_is_higher, :, :] - - preds = (preds + preds_bin) / 2. - - pred_texts = decode_batch_predictions(preds, self.model_zoo.get('num_to_char')) - - preds_max = np.max(preds, axis=2 ) - preds_max_args = np.argmax(preds, axis=2 ) - pred_max_not_unk_mask_bool = preds_max_args[:,:]!=self.end_character - masked_means = \ - np.sum(preds_max * pred_max_not_unk_mask_bool, axis=1) / \ - np.sum(pred_max_not_unk_mask_bool, axis=1) - - for ib in range(imgs.shape[0]): - pred_texts_ib = pred_texts[ib].replace("[UNK]", "") - if masked_means[ib] >= self.min_conf_value_of_textline_text: - extracted_texts.append(pred_texts_ib) - extracted_conf_value.append(masked_means[ib]) - else: + def nooov(x): + return x != b'[UNK]' + for pred, prob in zip(preds, probs): + if prob < self.min_conf_value_of_textline_text: extracted_texts.append("") - extracted_conf_value.append(0) - del cropped_lines + extracted_confs.append(0) + else: + text = b''.join( + filter(nooov, + map(bytes, + (filter(None, char) + for char in pred.tolist())))).decode('utf-8') + extracted_texts.append(text) + extracted_confs.append(prob) + del cropped_lines_rgb del cropped_lines_bin gc.collect() extracted_texts_merged = [extracted_texts[ind] - if cropped_lines_meging_indexing[ind]==0 - else extracted_texts[ind]+" "+extracted_texts[ind+1] - if cropped_lines_meging_indexing[ind]==1 - else None - for ind in range(len(cropped_lines_meging_indexing))] - - extracted_conf_value_merged = [extracted_conf_value[ind] # type: ignore - if cropped_lines_meging_indexing[ind]==0 - else (extracted_conf_value[ind]+extracted_conf_value[ind+1])/2. - if cropped_lines_meging_indexing[ind]==1 - else None - for ind in range(len(cropped_lines_meging_indexing))] - - extracted_conf_value_merged: List[float] = [extracted_conf_value_merged[ind_cfm] - for ind_cfm in range(len(extracted_texts_merged)) - if extracted_texts_merged[ind_cfm] is not None] - - extracted_texts_merged = [ind for ind in extracted_texts_merged if ind is not None] + if cropped_lines_meging_indexing[ind] == 0 + else extracted_texts[ind] + " " + extracted_texts[ind + 1] + for ind in range(len(cropped_lines_meging_indexing)) + if cropped_lines_meging_indexing[ind] >= 0] + extracted_confs_merged = [extracted_confs[ind] + if cropped_lines_meging_indexing[ind] == 0 + else 0.5 * (extracted_confs[ind] + extracted_confs[ind + 1]) + for ind in range(len(cropped_lines_meging_indexing)) + if cropped_lines_meging_indexing[ind] >= 0] return EynollahOcrResult( extracted_texts_merged=extracted_texts_merged, - extracted_conf_value_merged=extracted_conf_value_merged, + extracted_confs_merged=extracted_confs_merged, cropped_lines_region_indexer=cropped_lines_region_indexer, total_bb_coordinates=total_bb_coordinates, ) @@ -569,7 +373,7 @@ class Eynollah_ocr(Eynollah): cropped_lines_region_indexer = result.cropped_lines_region_indexer total_bb_coordinates = result.total_bb_coordinates extracted_texts_merged = result.extracted_texts_merged - extracted_conf_value_merged = result.extracted_conf_value_merged + extracted_confs_merged = result.extracted_confs_merged unique_cropped_lines_region_indexer = np.unique(cropped_lines_region_indexer) if out_image_with_text: @@ -646,8 +450,8 @@ class Eynollah_ocr(Eynollah): if not is_textline_text: text_subelement = ET.SubElement(child_textregion, 'TextEquiv') - if extracted_conf_value_merged: - text_subelement.set('conf', f"{extracted_conf_value_merged[indexer]:.2f}") + if extracted_confs_merged: + text_subelement.set('conf', f"{extracted_confs_merged[indexer]:.2f}") unicode_textline = ET.SubElement(text_subelement, 'Unicode') unicode_textline.text = extracted_texts_merged[indexer] else: @@ -655,8 +459,8 @@ class Eynollah_ocr(Eynollah): if childtest3.tag.endswith("TextEquiv"): for child_uc in childtest3: if child_uc.tag.endswith("Unicode"): - if extracted_conf_value_merged: - childtest3.set('conf', f"{extracted_conf_value_merged[indexer]:.2f}") + if extracted_confs_merged: + childtest3.set('conf', f"{extracted_confs_merged[indexer]:.2f}") child_uc.text = extracted_texts_merged[indexer] indexer = indexer + 1 diff --git a/src/eynollah/utils/__init__.py b/src/eynollah/utils/__init__.py index 47a765c..621b9ec 100644 --- a/src/eynollah/utils/__init__.py +++ b/src/eynollah/utils/__init__.py @@ -2,6 +2,7 @@ from typing import Iterable, List, Tuple from logging import getLogger import time import math +from itertools import islice try: import matplotlib.pyplot as plt @@ -33,6 +34,11 @@ def pairwise(iterable): yield a, b a = b +def batched(iterable, n): + iterator = iter(iterable) + while batch := tuple(islice(iterator, n)): + yield batch + def return_multicol_separators_x_start_end( regions_without_separators, peak_points, top, bot, x_min_hor_some, x_max_hor_some, cy_hor_some, y_min_hor_some, y_max_hor_some): diff --git a/src/eynollah/utils/utils_ocr.py b/src/eynollah/utils/utils_ocr.py index 817406c..6fc81fb 100644 --- a/src/eynollah/utils/utils_ocr.py +++ b/src/eynollah/utils/utils_ocr.py @@ -1,6 +1,5 @@ import math import copy -from itertools import islice import numpy as np import cv2 @@ -11,6 +10,7 @@ from scipy.signal import find_peaks from scipy.ndimage import gaussian_filter1d from PIL import Image, ImageDraw, ImageFont +from . import pairwise from .resize import resize_image @@ -41,45 +41,6 @@ def decode_batch_predictions(pred, num_to_char, max_len = 128): d = d.numpy().decode("utf-8") output.append(d) return output - - -def distortion_free_resize(image, img_size): - import tensorflow as tf - - w, h = img_size - image = tf.image.resize(image, size=(h, w), preserve_aspect_ratio=True) - - # Check tha amount of padding needed to be done. - pad_height = h - tf.shape(image)[0] - pad_width = w - tf.shape(image)[1] - - # Only necessary if you want to do same amount of padding on both sides. - if pad_height % 2 != 0: - height = pad_height // 2 - pad_height_top = height + 1 - pad_height_bottom = height - else: - pad_height_top = pad_height_bottom = pad_height // 2 - - if pad_width % 2 != 0: - width = pad_width // 2 - pad_width_left = width + 1 - pad_width_right = width - else: - pad_width_left = pad_width_right = pad_width // 2 - - image = tf.pad( - image, - paddings=[ - [pad_height_top, pad_height_bottom], - [pad_width_left, pad_width_right], - [0, 0], - ], - ) - - image = tf.transpose(image, (1, 0, 2)) - image = tf.image.flip_left_right(image) - return image def return_start_and_end_of_common_text_of_textline_ocr_without_common_section(textline_image): width = np.shape(textline_image)[1] @@ -263,254 +224,58 @@ def return_splitting_point_of_image(image_to_spliited): return np.sort(peaks_sort_4) -def break_curved_line_into_small_pieces_and_then_merge(img_curved, mask_curved, img_bin_curved=None): - peaks_4 = return_splitting_point_of_image(img_curved) - if len(peaks_4)>0: +def break_curved_line_into_small_pieces_and_then_merge(img_rgb_curved, img_bin_curved, mask_curved): + peaks_4 = return_splitting_point_of_image(img_rgb_curved) + if len(peaks_4): imgs_tot = [] - - for ind in range(len(peaks_4)+1): - if ind==0: - img = img_curved[:, :peaks_4[ind], :] - if img_bin_curved is not None: - img_bin = img_bin_curved[:, :peaks_4[ind], :] - mask = mask_curved[:, :peaks_4[ind], :] - elif ind==len(peaks_4): - img = img_curved[:, peaks_4[ind-1]:, :] - if img_bin_curved is not None: - img_bin = img_bin_curved[:, peaks_4[ind-1]:, :] - mask = mask_curved[:, peaks_4[ind-1]:, :] - else: - img = img_curved[:, peaks_4[ind-1]:peaks_4[ind], :] - if img_bin_curved is not None: - img_bin = img_bin_curved[:, peaks_4[ind-1]:peaks_4[ind], :] - mask = mask_curved[:, peaks_4[ind-1]:peaks_4[ind], :] - + for left, right in pairwise([None] + peaks_4 + [None]): + img_rgb = img_rgb_curved[:, left: right] + img_bin = img_bin_curved[:, left: right] + mask = mask_curved[:, left: right] or_ma = get_orientation_moments_of_mask(mask) - - if img_bin_curved is not None: - imgs_tot.append([img, mask, or_ma, img_bin] ) - else: - imgs_tot.append([img, mask, or_ma] ) - + imgs_tot.append([img_rgb, img_bin, mask, or_ma]) w_tot_des_list = [] - w_tot_des = 0 - imgs_deskewed_list = [] + imgs_rgb_deskewed_list = [] imgs_bin_deskewed_list = [] - for ind in range(len(imgs_tot)): - img_in = imgs_tot[ind][0] - mask_in = imgs_tot[ind][1] - ori_in = imgs_tot[ind][2] - if img_bin_curved is not None: - img_bin_in = imgs_tot[ind][3] - - if abs(ori_in)<45: - img_in_des = rotate_image_with_padding(img_in, ori_in, border_value=(255,255,255) ) - if img_bin_curved is not None: - img_bin_in_des = rotate_image_with_padding(img_bin_in, ori_in, border_value=(255,255,255) ) + for img_rgb_in, img_bin_in, mask_in, ori_in in imgs_tot: + if abs(ori_in) < 45: + img_rgb_in_des = rotate_image_with_padding(img_rgb_in, ori_in, border_value=(255,255,255) ) + img_bin_in_des = rotate_image_with_padding(img_bin_in, ori_in, border_value=(255,255,255) ) mask_in_des = rotate_image_with_padding(mask_in, ori_in) - mask_in_des = mask_in_des.astype('uint8') - - #new bounding box - x_n, y_n, w_n, h_n = get_contours_and_bounding_boxes(mask_in_des[:,:,0]) - - if w_n==0 or h_n==0: - img_in_des = np.copy(img_in) - if img_bin_curved is not None: - img_bin_in_des = np.copy(img_bin_in) - w_relative = int(32 * img_in_des.shape[1]/float(img_in_des.shape[0]) ) - if w_relative==0: - w_relative = img_in_des.shape[1] - img_in_des = resize_image(img_in_des, 32, w_relative) - if img_bin_curved is not None: - img_bin_in_des = resize_image(img_bin_in_des, 32, w_relative) + # get new bounding box + x_n, y_n, w_n, h_n = get_contours_and_bounding_boxes(mask_in_des) + if w_n and h_n: + img_rgb_in_des = img_rgb_in_des[y_n: y_n + h_n, x_n: x_n + w_n] + img_bin_in_des = img_bin_in_des[y_n: y_n + h_n, x_n: x_n + w_n] else: - mask_in_des = mask_in_des[y_n:y_n+h_n, x_n:x_n+w_n, :] - img_in_des = img_in_des[y_n:y_n+h_n, x_n:x_n+w_n, :] - if img_bin_curved is not None: - img_bin_in_des = img_bin_in_des[y_n:y_n+h_n, x_n:x_n+w_n, :] - - w_relative = int(32 * img_in_des.shape[1]/float(img_in_des.shape[0]) ) - if w_relative==0: - w_relative = img_in_des.shape[1] - img_in_des = resize_image(img_in_des, 32, w_relative) - if img_bin_curved is not None: - img_bin_in_des = resize_image(img_bin_in_des, 32, w_relative) - - - else: - img_in_des = np.copy(img_in) - if img_bin_curved is not None: + img_rgb_in_des = np.copy(img_rgb_in) img_bin_in_des = np.copy(img_bin_in) - w_relative = int(32 * img_in_des.shape[1]/float(img_in_des.shape[0]) ) - if w_relative==0: - w_relative = img_in_des.shape[1] - img_in_des = resize_image(img_in_des, 32, w_relative) - if img_bin_curved is not None: - img_bin_in_des = resize_image(img_bin_in_des, 32, w_relative) - - w_tot_des+=img_in_des.shape[1] - w_tot_des_list.append(img_in_des.shape[1]) - imgs_deskewed_list.append(img_in_des) - if img_bin_curved is not None: - imgs_bin_deskewed_list.append(img_bin_in_des) - - - + else: + img_rgb_in_des = np.copy(img_rgb_in) + img_bin_in_des = np.copy(img_bin_in) - img_final_deskewed = np.zeros((32, w_tot_des, 3))+255 - if img_bin_curved is not None: - img_bin_final_deskewed = np.zeros((32, w_tot_des, 3))+255 - else: - img_bin_final_deskewed = None + h, w = img_rgb_in_des.shape[:2] + new_h = 32 + new_w = 32 * w // h + new_w = new_w or w + img_rgb_in_des = resize_image(img_rgb_in_des, new_h, new_w) + img_bin_in_des = resize_image(img_bin_in_des, new_h, new_w) + + w_tot_des_list.append(new_w) + imgs_rgb_deskewed_list.append(img_rgb_in_des) + imgs_bin_deskewed_list.append(img_bin_in_des) + + img_rgb_final_deskewed = np.ones((new_h, sum(w_tot_des_list), 3)) * 255 + img_bin_final_deskewed = np.ones((new_h, sum(w_tot_des_list), 3)) * 255 w_indexer = 0 for ind in range(len(w_tot_des_list)): - img_final_deskewed[:,w_indexer:w_indexer+w_tot_des_list[ind],:] = imgs_deskewed_list[ind][:,:,:] - if img_bin_curved is not None: - img_bin_final_deskewed[:,w_indexer:w_indexer+w_tot_des_list[ind],:] = imgs_bin_deskewed_list[ind][:,:,:] - w_indexer = w_indexer+w_tot_des_list[ind] - return img_final_deskewed, img_bin_final_deskewed + w_indexer2 = w_indexer + w_tot_des_list[ind] + img_rgb_final_deskewed[:, w_indexer: w_indexer2] = imgs_rgb_deskewed_list[ind] + img_bin_final_deskewed[:, w_indexer: w_indexer2] = imgs_bin_deskewed_list[ind] + w_indexer = w_indexer2 + return img_rgb_final_deskewed, img_bin_final_deskewed else: - return img_curved, img_bin_curved - -def return_textline_contour_with_added_box_coordinate(textline_contour, box_ind): - textline_contour[:,:,0] += box_ind[2] - textline_contour[:,:,1] += box_ind[0] - return textline_contour - - -def return_rnn_cnn_ocr_of_given_textlines(image, - all_found_textline_polygons, - all_box_coord, - prediction_model, - b_s_ocr, num_to_char, - curved_line=False): - max_len = 512 - padding_token = 299 - image_width = 512#max_len * 4 - image_height = 32 - ind_tot = 0 - #cv2.imwrite('./img_out.png', image_page) - ocr_all_textlines = [] - cropped_lines_region_indexer = [] - cropped_lines_meging_indexing = [] - cropped_lines = [] - indexer_text_region = 0 - - for indexing, ind_poly_first in enumerate(all_found_textline_polygons): - #ocr_textline_in_textregion = [] - if len(ind_poly_first)==0: - cropped_lines_region_indexer.append(indexer_text_region) - cropped_lines_meging_indexing.append(0) - img_fin = np.ones((image_height, image_width, 3))*1 - cropped_lines.append(img_fin) - - else: - for indexing2, ind_poly in enumerate(ind_poly_first): - cropped_lines_region_indexer.append(indexer_text_region) - if not curved_line: - ind_poly = copy.deepcopy(ind_poly) - box_ind = all_box_coord[indexing] - - ind_poly = return_textline_contour_with_added_box_coordinate(ind_poly, box_ind) - #print(ind_poly_copy) - ind_poly[ind_poly<0] = 0 - x, y, w, h = cv2.boundingRect(ind_poly) - - w_scaled = w * image_height/float(h) - - mask_poly = np.zeros(image.shape) - - img_poly_on_img = np.copy(image) - - mask_poly = cv2.fillPoly(mask_poly, pts=[ind_poly], color=(1, 1, 1)) - - - - mask_poly = mask_poly[y:y+h, x:x+w, :] - img_crop = img_poly_on_img[y:y+h, x:x+w, :] - - img_crop[mask_poly==0] = 255 - - if w_scaled < 640:#1.5*image_width: - img_fin = preprocess_and_resize_image_for_ocrcnn_model(img_crop, image_height, image_width) - cropped_lines.append(img_fin) - cropped_lines_meging_indexing.append(0) - else: - splited_images, splited_images_bin = return_textlines_split_if_needed(img_crop, None) - - if splited_images: - img_fin = preprocess_and_resize_image_for_ocrcnn_model(splited_images[0], - image_height, - image_width) - cropped_lines.append(img_fin) - cropped_lines_meging_indexing.append(1) - - img_fin = preprocess_and_resize_image_for_ocrcnn_model(splited_images[1], - image_height, - image_width) - - cropped_lines.append(img_fin) - cropped_lines_meging_indexing.append(-1) - - else: - img_fin = preprocess_and_resize_image_for_ocrcnn_model(img_crop, - image_height, - image_width) - cropped_lines.append(img_fin) - cropped_lines_meging_indexing.append(0) - - indexer_text_region+=1 - - extracted_texts = [] - - n_iterations = math.ceil(len(cropped_lines) / b_s_ocr) - - for i in range(n_iterations): - if i==(n_iterations-1): - n_start = i*b_s_ocr - imgs = cropped_lines[n_start:] - imgs = np.array(imgs) - imgs = imgs.reshape(imgs.shape[0], image_height, image_width, 3) - - - else: - n_start = i*b_s_ocr - n_end = (i+1)*b_s_ocr - imgs = cropped_lines[n_start:n_end] - imgs = np.array(imgs).reshape(b_s_ocr, image_height, image_width, 3) - - - preds = prediction_model.predict(imgs, verbose=0) - - pred_texts = decode_batch_predictions(preds, num_to_char) - - for ib in range(imgs.shape[0]): - pred_texts_ib = pred_texts[ib].replace("[UNK]", "") - extracted_texts.append(pred_texts_ib) - - extracted_texts_merged = [extracted_texts[ind] - if cropped_lines_meging_indexing[ind]==0 - else extracted_texts[ind]+" "+extracted_texts[ind+1] - if cropped_lines_meging_indexing[ind]==1 - else None - for ind in range(len(cropped_lines_meging_indexing))] - - extracted_texts_merged = [ind for ind in extracted_texts_merged if ind is not None] - unique_cropped_lines_region_indexer = np.unique(cropped_lines_region_indexer) - - ocr_all_textlines = [] - for ind in unique_cropped_lines_region_indexer: - ocr_textline_in_textregion = [] - extracted_texts_merged_un = np.array(extracted_texts_merged)[np.array(cropped_lines_region_indexer)==ind] - for it_ind, text_textline in enumerate(extracted_texts_merged_un): - ocr_textline_in_textregion.append(text_textline) - ocr_all_textlines.append(ocr_textline_in_textregion) - return ocr_all_textlines - -def batched(iterable, n): - iterator = iter(iterable) - while batch := tuple(islice(iterator, n)): - yield batch + return img_rgb_curved, img_bin_curved From d2f2a1e06b3632b11b0dbf7c40ed59bc50ee5d44 Mon Sep 17 00:00:00 2001 From: Robert Sachunsky Date: Wed, 3 Jun 2026 00:43:46 +0200 Subject: [PATCH 70/77] =?UTF-8?q?Eynollah=5Focr:=20correctly=20handle=20mi?= =?UTF-8?q?n=5Fconf,=20improve=20writer=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `min_conf_value_of_textline_text`: apply by skipping lines below threshold (instead of writing empty text), and delete their TextEquiv (if existing) - `write_ocr()`: simplify, and ensure consistency between line and region level text correctly --- src/eynollah/eynollah_ocr.py | 137 +++++++++++++---------------------- 1 file changed, 50 insertions(+), 87 deletions(-) diff --git a/src/eynollah/eynollah_ocr.py b/src/eynollah/eynollah_ocr.py index 40cbeaa..aeaabfe 100644 --- a/src/eynollah/eynollah_ocr.py +++ b/src/eynollah/eynollah_ocr.py @@ -41,7 +41,7 @@ from .utils.utils_ocr import ( @dataclass class EynollahOcrResult: extracted_texts_merged: List - extracted_confs_merged: Optional[List] + extracted_confs_merged: List cropped_lines_region_indexer: List total_bb_coordinates:List @@ -156,10 +156,6 @@ class Eynollah_ocr(Eynollah): conf = output.sequences_scores.exp().clamp(0.0, 1.0).tolist() else: conf = [1.0] * len(output.sequences) - if conf < self.min_conf_value_of_textline_text: - extracted_confs.extend(0) - extracted_texts.extend("") - continue text = self.model_zoo.get('trocr_processor').batch_decode( output.sequences, skip_special_tokens=True, @@ -327,17 +323,13 @@ class Eynollah_ocr(Eynollah): def nooov(x): return x != b'[UNK]' for pred, prob in zip(preds, probs): - if prob < self.min_conf_value_of_textline_text: - extracted_texts.append("") - extracted_confs.append(0) - else: - text = b''.join( - filter(nooov, - map(bytes, - (filter(None, char) - for char in pred.tolist())))).decode('utf-8') - extracted_texts.append(text) - extracted_confs.append(prob) + text = b''.join( + filter(nooov, + map(bytes, + (filter(None, char) + for char in pred.tolist())))).decode('utf-8') + extracted_texts.append(text) + extracted_confs.append(prob) del cropped_lines_rgb del cropped_lines_bin gc.collect() @@ -375,7 +367,6 @@ class Eynollah_ocr(Eynollah): extracted_texts_merged = result.extracted_texts_merged extracted_confs_merged = result.extracted_confs_merged - unique_cropped_lines_region_indexer = np.unique(cropped_lines_region_indexer) if out_image_with_text: image_text = Image.new("RGB", (img.shape[1], img.shape[0]), "white") draw = ImageDraw.Draw(image_text) @@ -403,78 +394,50 @@ class Eynollah_ocr(Eynollah): draw.text((text_x, text_y), extracted_texts_merged[indexer_text], fill="black", font=font) image_text.save(out_image_with_text) - text_by_textregion = [] - for ind in unique_cropped_lines_region_indexer: - ind = np.array(cropped_lines_region_indexer)==ind - extracted_texts_merged_un = np.array(extracted_texts_merged)[ind] - if len(extracted_texts_merged_un)>1: - text_by_textregion_ind = "" - next_glue = "" - for indt in range(len(extracted_texts_merged_un)): - if (extracted_texts_merged_un[indt].endswith('⸗') or - extracted_texts_merged_un[indt].endswith('-') or - extracted_texts_merged_un[indt].endswith('¬')): - text_by_textregion_ind += next_glue + extracted_texts_merged_un[indt][:-1] - next_glue = "" - else: - text_by_textregion_ind += next_glue + extracted_texts_merged_un[indt] - next_glue = " " - text_by_textregion.append(text_by_textregion_ind) - else: - text_by_textregion.append(" ".join(extracted_texts_merged_un)) + cropped_lines_region_indexer = np.array(cropped_lines_region_indexer) + for n_region, region in enumerate(page_tree.getroot().iter('{%s}TextRegion' % page_ns)): + lines_indexer = np.flatnonzero(cropped_lines_region_indexer == n_region) + if not len(lines_indexer): + continue - indexer = 0 - indexer_textregion = 0 - for nn in page_tree.getroot().iter(f'{{{page_ns}}}TextRegion'): - - is_textregion_text = False - for childtest in nn: - if childtest.tag.endswith("TextEquiv"): - is_textregion_text = True - - if not is_textregion_text: - text_subelement_textregion = ET.SubElement(nn, 'TextEquiv') - unicode_textregion = ET.SubElement(text_subelement_textregion, 'Unicode') - - - has_textline = False - for child_textregion in nn: - # FIXME: should remove Word level, if it already exists - if child_textregion.tag.endswith("TextLine"): - - is_textline_text = False - for childtest2 in child_textregion: - if childtest2.tag.endswith("TextEquiv"): - is_textline_text = True - - - if not is_textline_text: - text_subelement = ET.SubElement(child_textregion, 'TextEquiv') - if extracted_confs_merged: - text_subelement.set('conf', f"{extracted_confs_merged[indexer]:.2f}") - unicode_textline = ET.SubElement(text_subelement, 'Unicode') - unicode_textline.text = extracted_texts_merged[indexer] - else: - for childtest3 in child_textregion: - if childtest3.tag.endswith("TextEquiv"): - for child_uc in childtest3: - if child_uc.tag.endswith("Unicode"): - if extracted_confs_merged: - childtest3.set('conf', f"{extracted_confs_merged[indexer]:.2f}") - child_uc.text = extracted_texts_merged[indexer] - - indexer = indexer + 1 - has_textline = True - if has_textline: - if is_textregion_text: - for child4 in nn: - if child4.tag.endswith("TextEquiv"): - for childtr_uc in child4: - if childtr_uc.tag.endswith("Unicode"): - childtr_uc.text = text_by_textregion[indexer_textregion] + text_region = "" + next_glue = "" + for line_idx in lines_indexer: + if extracted_confs_merged[line_idx] < self.min_conf_value_of_textline_text: + continue + text_line = extracted_texts_merged[line_idx] + if (text_line.endswith(('⸗', '-', '¬')) and + # last line of a region can still be wrapped + # around columns or pages + line_idx < len(lines_indexer) - 1): + text_region += next_glue + text_line[:-1] + next_glue = "" else: - unicode_textregion.text = text_by_textregion[indexer_textregion] - indexer_textregion = indexer_textregion + 1 + text_region += next_glue + text_line + next_glue = " " + + region_textequiv = region.find('{%s}TextEquiv' % page_ns) + if region_textequiv is None: + region_textequiv = ET.SubElement(region, 'TextEquiv') + region_teunicode = region_textequiv.find('{%s}Unicode' % page_ns) + if region_teunicode is None: + region_teunicode = ET.SubElement(region_textequiv, 'Unicode') + region_teunicode.text = text_region + + for n_line, line in enumerate(region.iter('{%s}TextLine' % page_ns)): + line_textequiv = line.find('{%s}TextEquiv' % page_ns) + if line_textequiv is None: + line_textequiv = ET.SubElement(line, 'TextEquiv') + line_teunicode = line_textequiv.find('{%s}Unicode' % page_ns) + if line_teunicode is None: + line_teunicode = ET.SubElement(line_textequiv, 'Unicode') + + line_idx = lines_indexer[n_line] + if extracted_confs_merged[line_idx] < self.min_conf_value_of_textline_text: + line.remove(line_textequiv) + else: + line_textequiv.set('conf', str(round(extracted_confs_merged[line_idx], 2))) + line_teunicode.text = extracted_texts_merged[line_idx] ET.register_namespace("",page_ns) self.logger.info("output filename: '%s'", out_file_ocr) From f447a9f248d0d48ea6f4a6fc7185ae82484c8ac3 Mon Sep 17 00:00:00 2001 From: Robert Sachunsky Date: Wed, 3 Jun 2026 03:41:44 +0200 Subject: [PATCH 71/77] =?UTF-8?q?trocr:=20move=20preprocessor=20and=20deco?= =?UTF-8?q?der=20into=20model=20object,=20too=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ModelZoo: drop `trocr_processor` model type - `ModelZoo.load_models()`: use Predictor for `ocr_tr` models, too - `ModelZoo.load_model()`: for `ocr_tr`, load processor and model, then define a function object as stand-in for the common model interface based on Keras (w/ `.predict_on_batch()`) - Predictor: allow multi-input without actual batch dimension for `ocr_tr` models (because the model takes a list of original image arrays and resizes them to model shape internally) - Eynollah_ocr: adapt (replacing preprocessing, prediction and decoding steps by a single `.predict()` call) --- src/eynollah/eynollah_ocr.py | 22 +---- src/eynollah/model_zoo/default_specs.py | 16 ---- src/eynollah/model_zoo/model_zoo.py | 121 ++++++++++++++---------- src/eynollah/predictor.py | 15 ++- 4 files changed, 88 insertions(+), 86 deletions(-) diff --git a/src/eynollah/eynollah_ocr.py b/src/eynollah/eynollah_ocr.py index aeaabfe..1dfe177 100644 --- a/src/eynollah/eynollah_ocr.py +++ b/src/eynollah/eynollah_ocr.py @@ -70,8 +70,7 @@ class Eynollah_ocr(Eynollah): def setup_models(self, device=''): if self.tr_ocr: - self.model_zoo.load_models('trocr_processor', - ('ocr', 'tr'), + self.model_zoo.load_models(('ocr', 'tr'), device=device) else: self.model_zoo.load_models('ocr', @@ -142,24 +141,7 @@ class Eynollah_ocr(Eynollah): self.logger.debug("processing %d lines for %d regions", len(cropped_lines), len(set(cropped_lines_region_indexer))) for imgs in batched(cropped_lines, self.b_s): - pixel_values = self.model_zoo.get('trocr_processor')( - imgs, return_tensors="pt").pixel_values - output = self.model_zoo.get('ocr').generate( - pixel_values.to(self.device), - # beam search instead of greedy decoding: - num_beams=4, - # also return probability - output_scores=True, - return_dict_in_generate=True) - if output.sequences_scores is not None: - # log-prob averaged over length - conf = output.sequences_scores.exp().clamp(0.0, 1.0).tolist() - else: - conf = [1.0] * len(output.sequences) - text = self.model_zoo.get('trocr_processor').batch_decode( - output.sequences, - skip_special_tokens=True, - clean_up_tokenization_spaces=False) + text, conf = self.model_zoo.get('ocr').predict(imgs) extracted_confs.extend(conf) extracted_texts.extend(text) del cropped_lines diff --git a/src/eynollah/model_zoo/default_specs.py b/src/eynollah/model_zoo/default_specs.py index 170d944..18bf093 100644 --- a/src/eynollah/model_zoo/default_specs.py +++ b/src/eynollah/model_zoo/default_specs.py @@ -217,20 +217,4 @@ DEFAULT_MODEL_SPECS = EynollahModelSpecSet([ type='Keras', ), - EynollahModelSpec( - category="trocr_processor", - variant='', - filename="models_eynollah/model_eynollah_ocr_trocr_20250919", - dist_url=dist_url("ocr"), - type='TrOCRProcessor', - ), - - EynollahModelSpec( - category="trocr_processor", - variant='htr', - filename="models_eynollah/microsoft/trocr-base-handwritten", - dist_url=dist_url("extra"), - type='TrOCRProcessor', - ), - ]) diff --git a/src/eynollah/model_zoo/model_zoo.py b/src/eynollah/model_zoo/model_zoo.py index e7d21aa..0dd24a8 100644 --- a/src/eynollah/model_zoo/model_zoo.py +++ b/src/eynollah/model_zoo/model_zoo.py @@ -116,22 +116,15 @@ class EynollahModelZoo: model_category, model_variant = load_args load_kwargs["model_variant"] = model_variant - if model_category.endswith('_resized'): - model_category = model_category[:-8] - load_kwargs["resized"] = True - elif model_category.endswith('_patched'): - model_category = model_category[:-8] - load_kwargs["patched"] = True + # if model_category.endswith('_resized'): + # model_category = model_category[:-8] + # load_kwargs["resized"] = True + # elif model_category.endswith('_patched'): + # model_category = model_category[:-8] + # load_kwargs["patched"] = True - if model_category == 'ocr' and model_variant == 'tr': - model = self._load_ocr_model(variant=model_variant, device=device) - elif model_category == 'trocr_processor': - from transformers import TrOCRProcessor - model_path = self.model_path(model_category, model_variant) - model = TrOCRProcessor.from_pretrained(model_path) - else: - model = Predictor(self.logger, self) - model.load_model(model_category, **load_kwargs) + model = Predictor(self.logger, self) + model.load_model(model_category, **load_kwargs) ret[model_category] = model self._loaded.update(ret) @@ -142,8 +135,8 @@ class EynollahModelZoo: model_category: str, model_variant: str = '', model_path_override: Optional[str] = None, - patched: bool = False, - resized: bool = False, + # patched: bool = False, + # resized: bool = False, device: str = '', ) -> AnyModel: """ @@ -153,7 +146,9 @@ class EynollahModelZoo: self.override_models((model_category, model_variant, model_path_override)) model_path = self.model_path(model_category, model_variant) - if model_path.is_dir() and (model_path / "keras_metadata.pb").exists(): + if model_category == 'ocr' and model_variant == 'tr': + model = self._load_trocr_model(model_path, device=device) + elif model_path.is_dir() and (model_path / "keras_metadata.pb").exists(): # Keras model model = self._load_keras_model(model_category, model_path, device=device) elif model_path.is_dir(): @@ -220,6 +215,30 @@ class EynollahModelZoo: if not cuda: self.logger.warning("no GPU device available") + def _configure_torch_device(self, model_category, device=''): + import torch + + device0 = torch.device('cpu') + if not device and torch.cuda.is_available(): + device = 'GPU' # try + if device and ':' in device: + for spec in device.split(','): + cat, dev = spec.split(':') + if fnmatchcase('ocr', cat): + device = dev + break + if device and device.startswith('GPU'): + try: + device0 = torch.device('cuda', int(device[3:] or 0)) + name = torch.cuda.get_device_name(device0) + self.logger.info("using GPU %s (%s) for model ocr:tr", device0, name) + except: + self.logger.exception("cannot configure GPU device") + device0 = torch.device('cpu') + if device0.type != 'cuda': + self.logger.warning("no GPU device available") + return device0 + def _load_keras_model(self, model_category, model_path, device=''): os.environ['TF_USE_LEGACY_KERAS'] = '1' # avoid Keras 3 after TF 2.15 from ocrd_utils import tf_disable_interactive_logs @@ -325,40 +344,46 @@ class EynollahModelZoo: return model - def _load_ocr_model(self, variant: str, device: str = "") -> AnyModel: + def _load_trocr_model(self, model_path, device: str = "") -> AnyModel: """ Load OCR model """ - model_dir = self.model_path('ocr', variant) - if variant == 'tr': - from transformers import VisionEncoderDecoderModel - import torch - model = VisionEncoderDecoderModel.from_pretrained(model_dir) - assert isinstance(model, VisionEncoderDecoderModel) - device0 = torch.device('cpu') - if not device and torch.cuda.is_available(): - device = 'GPU' # try - if device and ':' in device: - for spec in device.split(','): - cat, dev = spec.split(':') - if fnmatchcase('ocr', cat): - device = dev - break - if device and device.startswith('GPU'): - try: - device0 = torch.device('cuda', int(device[3:] or 0)) - name = torch.cuda.get_device_name(device0) - self.logger.info("using GPU %s (%s) for model ocr:tr", device0, name) - except: - self.logger.exception("cannot configure GPU device") - device0 = torch.device('cpu') - if device0.type == 'cuda': - model.to(device0) - else: - self.logger.warning("no GPU device available") - return model + from transformers import VisionEncoderDecoderModel, TrOCRProcessor + import numpy as np - return self.load_model('ocr', model_variant=variant, device=device) + device = self._configure_torch_device('ocr', device=device) + proc = TrOCRProcessor.from_pretrained(model_path) + model = VisionEncoderDecoderModel.from_pretrained(model_path) + assert isinstance(model, VisionEncoderDecoderModel) + + model.to(device) + def predict_torch(inputs): + output = model.generate( + proc(inputs, return_tensors="pt").pixel_values.to(device), + # beam search instead of greedy decoding: + num_beams=4, + # also return probability + output_scores=True, + return_dict_in_generate=True) + if output.sequences_scores is not None: + # log-prob averaged over length + conf = output.sequences_scores.exp().clamp(0.0, 1.0).cpu().numpy() + else: + conf = np.ones(len(output.sequences), dtype=float) + text = proc.batch_decode( + output.sequences, + skip_special_tokens=True, + clean_up_tokenization_spaces=False) + # we must convert to ndarray for Predictor resultq to work + text = np.array(text) + return text, conf + model.predict_on_batch = predict_torch + # not actually needed (image processor does resize itself) + model.input_shape = (None, + proc.image_processor.size.height, + proc.image_processor.size.width, + len(proc.image_processor.image_mean)) + return model def __str__(self): return tabulate( diff --git a/src/eynollah/predictor.py b/src/eynollah/predictor.py index 141d3f0..23cc36f 100644 --- a/src/eynollah/predictor.py +++ b/src/eynollah/predictor.py @@ -129,6 +129,7 @@ class Predictor(mp.context.SpawnProcess): "enhancement": 4, "reading_order": 4, "ocr": 8, + "ocr_tr": 2, # medium size (672x672x3)... "textline": 2, # large models... @@ -144,7 +145,14 @@ class Predictor(mp.context.SpawnProcess): self.resultq.put((jobid, result)) #self.logger.debug("sent result for '%d': %s", jobid, result) else: - if isinstance(shared_data, tuple): + if self.name == 'ocr_tr': + # this model takes a list of (image) tensors + # of heterogeneous shape as input, + # resizing them internally; + # so this looks like multi-input + multi_input = True + batch_size = len(shared_data) + elif isinstance(shared_data, tuple): multi_input = True batch_size = shared_data[0]['shape'][0] else: @@ -215,8 +223,11 @@ class Predictor(mp.context.SpawnProcess): def load_model(self, *load_args, **load_kwargs): assert len(load_args) self.name = '_'.join(list(load_args[:1]) + + list(load_kwargs[key] for key in load_kwargs + if key == 'model_variant') + list(key for key in load_kwargs - if key != 'device')) + if key in ['patched', 'resized'] + and load_kwargs[key])) self.load_args = load_args self.load_kwargs = load_kwargs self.start() # call run() in subprocess From 4e7e1c06b95e6a761f2232350fa4946452e93be2 Mon Sep 17 00:00:00 2001 From: Robert Sachunsky Date: Wed, 3 Jun 2026 20:51:56 +0200 Subject: [PATCH 72/77] =?UTF-8?q?trocr=20viarant=20for=20Predictor=20runti?= =?UTF-8?q?me:=20no=20model=20size=20for=20input=5Fshape=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Because transformers v4 and v5 API for image preprocessor differs, and the model-internal image input sizes are actually irrelevant, because the preprocessor will resize them anyway, and there is no batch dimension (because the input images will have different shapes), do not advertise this information in `.input_shape`. --- src/eynollah/model_zoo/model_zoo.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/eynollah/model_zoo/model_zoo.py b/src/eynollah/model_zoo/model_zoo.py index 0dd24a8..49ed8e1 100644 --- a/src/eynollah/model_zoo/model_zoo.py +++ b/src/eynollah/model_zoo/model_zoo.py @@ -379,9 +379,9 @@ class EynollahModelZoo: return text, conf model.predict_on_batch = predict_torch # not actually needed (image processor does resize itself) + # no batch dimension (images passed as list w/ varying shapes) model.input_shape = (None, - proc.image_processor.size.height, - proc.image_processor.size.width, + None, len(proc.image_processor.image_mean)) return model From 38fe4d33add7de3bf819d9378cc1d1e2266ea1ab Mon Sep 17 00:00:00 2001 From: Robert Sachunsky Date: Wed, 3 Jun 2026 20:56:00 +0200 Subject: [PATCH 73/77] =?UTF-8?q?Predictor=20for=20multi-input=20models:?= =?UTF-8?q?=20present=20as=20list=20instead=20of=20tuple=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit (because TF-Serving expects that and cannot cast) --- src/eynollah/predictor.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/eynollah/predictor.py b/src/eynollah/predictor.py index 23cc36f..6790676 100644 --- a/src/eynollah/predictor.py +++ b/src/eynollah/predictor.py @@ -184,8 +184,8 @@ class Predictor(mp.context.SpawnProcess): else: data.append(stack.enter_context(ndarray_shared(shared_data))) if multi_input: - data = tuple(np.concatenate(data0) - for data0 in zip(*data)) + data = list(np.concatenate(data0) + for data0 in zip(*data)) else: data = np.concatenate(data) #result = self.model.predict(data, verbose=0) From 27ca9733db22d9a406b25d69f20654f4b9f44743 Mon Sep 17 00:00:00 2001 From: Robert Sachunsky Date: Wed, 3 Jun 2026 20:57:02 +0200 Subject: [PATCH 74/77] ModelZoo ONNX backend for inference: support multi-input or -output --- src/eynollah/model_zoo/model_zoo.py | 25 ++++++++++++++++++------- 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/src/eynollah/model_zoo/model_zoo.py b/src/eynollah/model_zoo/model_zoo.py index 49ed8e1..51ce909 100644 --- a/src/eynollah/model_zoo/model_zoo.py +++ b/src/eynollah/model_zoo/model_zoo.py @@ -331,14 +331,25 @@ class EynollahModelZoo: model_path, providers=providers) # FIXME: notify about selected provider/device - input_name = model.get_inputs()[0].name - output_name = model.get_outputs()[0].name + model_inputs = [model_input.name + for model_input in model.get_inputs()] + model_outputs = [model_output.name + for model_output in model.get_outputs()] def predict_onnx(inputs): - # models expect data_type() == 'tensor(float)', but np.float16 is 'tensor(float16)' - # FIXME: do this dynamically (but how to convert .type to np.dtype?) - inputs = inputs.astype(np.float32) - return model.run( - [output_name], {input_name: inputs})[0] + if len(model_inputs) == 1: + inputs = [inputs] + outputs = model.run(model_outputs, { + model_input: + input_data.astype( + # models expect data_type() == 'tensor(float)', but np.float16 is 'tensor(float16)' + # FIXME: do this dynamically (but how to convert .type to np.dtype?) + np.float32 if input_data.dtype in [np.float16, np.float64] else + input_data.dtype) + for model_input, input_data in zip(model_inputs, inputs) + }) + if len(model_outputs) == 1: + outputs = outputs[0] + return outputs model.predict_on_batch = predict_onnx model.input_shape = model.get_inputs()[0].shape From 24c7d4c277402524f15c703d021c00fc81d53956 Mon Sep 17 00:00:00 2001 From: Robert Sachunsky Date: Wed, 3 Jun 2026 20:58:05 +0200 Subject: [PATCH 75/77] update trocr smoke test, add cnnrnn ocr smoke test --- tests/test_model_zoo.py | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/tests/test_model_zoo.py b/tests/test_model_zoo.py index 341bc21..2902bfe 100644 --- a/tests/test_model_zoo.py +++ b/tests/test_model_zoo.py @@ -1,16 +1,28 @@ from eynollah.model_zoo import EynollahModelZoo +from eynollah.predictor import Predictor def test_trocr1( model_dir, ): model_zoo = EynollahModelZoo(model_dir) try: - from transformers import TrOCRProcessor, VisionEncoderDecoderModel - model_zoo.load_models('trocr_processor', - ('ocr', 'tr')) - proc = model_zoo.get('trocr_processor') - assert isinstance(proc, TrOCRProcessor) + model_zoo.load_models(('ocr', 'tr')) model = model_zoo.get('ocr') - assert isinstance(model, VisionEncoderDecoderModel) + assert isinstance(model, Predictor) + shape = model.input_shape + assert len(shape) == 3 + except ImportError: + pass + +def test_cnnrnnocr1( + model_dir, +): + model_zoo = EynollahModelZoo(model_dir) + try: + model_zoo.load_models('ocr') + model = model_zoo.get('ocr') + assert isinstance(model, Predictor) + shape = model.input_shape + assert len(shape) == 4 except ImportError: pass From 348ac95ad37fe82c86413e8aa54bf781a813ade2 Mon Sep 17 00:00:00 2001 From: Robert Sachunsky Date: Wed, 3 Jun 2026 20:59:00 +0200 Subject: [PATCH 76/77] =?UTF-8?q?Eynollah=5Focr:=20drop=20fixed=20input=20?= =?UTF-8?q?sizes=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - tr-ocr: no need to resize images in advance (done by model, anyway) - cnn-rnn-ocr: get model size from model's input shape --- src/eynollah/eynollah_ocr.py | 25 ++++++------------------- 1 file changed, 6 insertions(+), 19 deletions(-) diff --git a/src/eynollah/eynollah_ocr.py b/src/eynollah/eynollah_ocr.py index 1dfe177..76e54a7 100644 --- a/src/eynollah/eynollah_ocr.py +++ b/src/eynollah/eynollah_ocr.py @@ -87,9 +87,8 @@ class Eynollah_ocr(Eynollah): img: MatLike, page_tree: ET.ElementTree, page_ns, - tr_ocr_input_height_and_width, ) -> EynollahOcrResult: - + total_bb_coordinates = [] cropped_lines = [] cropped_lines_region_indexer = [] @@ -117,20 +116,14 @@ class Eynollah_ocr(Eynollah): img_crop[mask_poly == 0] = 255 # FIXME: or median color? if h > 0.1 * w: - cropped_lines.append(resize_image(img_crop, - tr_ocr_input_height_and_width, - tr_ocr_input_height_and_width) ) + cropped_lines.append(img_crop) cropped_lines_meging_indexing.append(0) else: splited_images, _ = return_textlines_split_if_needed(img_crop, None) if splited_images: - cropped_lines.append(resize_image(splited_images[0], - tr_ocr_input_height_and_width, - tr_ocr_input_height_and_width)) + cropped_lines.append(splited_images[0]) + cropped_lines.append(splited_images[1]) cropped_lines_meging_indexing.append(1) - cropped_lines.append(resize_image(splited_images[1], - tr_ocr_input_height_and_width, - tr_ocr_input_height_and_width)) cropped_lines_meging_indexing.append(-1) else: cropped_lines.append(img_crop) @@ -172,10 +165,9 @@ class Eynollah_ocr(Eynollah): img_bin: Optional[MatLike], page_tree: ET.ElementTree, page_ns, - image_width, - image_height, ) -> EynollahOcrResult: - + _, image_height, image_width, _ = self.model_zoo.get('ocr').input_shape + total_bb_coordinates = [] cropped_lines_rgb = [] cropped_lines_bin = [] @@ -482,18 +474,13 @@ class Eynollah_ocr(Eynollah): img=img, page_tree=page_tree, page_ns=page_ns, - - tr_ocr_input_height_and_width = 384 ) else: result = self.run_cnn( img=img, page_tree=page_tree, page_ns=page_ns, - img_bin=img_bin, - image_width=512, - image_height=32, ) self.write_ocr( From bed7fe526b5c6bae0d05de8efba508d681dca53c Mon Sep 17 00:00:00 2001 From: kba Date: Thu, 11 Jun 2026 17:04:56 +0200 Subject: [PATCH 77/77] remove (half-implemented) page_alto functionality --- src/eynollah/training/extract_line_gt.py | 201 ++++---------- .../training/generate_gt_for_training.py | 10 +- src/eynollah/training/gt_gen_utils.py | 249 ++++++++---------- 3 files changed, 169 insertions(+), 291 deletions(-) diff --git a/src/eynollah/training/extract_line_gt.py b/src/eynollah/training/extract_line_gt.py index 819bac1..4600e79 100644 --- a/src/eynollah/training/extract_line_gt.py +++ b/src/eynollah/training/extract_line_gt.py @@ -56,12 +56,6 @@ from ..utils import is_image_filename is_flag=True, help="if this parameter set to true, vertical textline images will be excluded.", ) -@click.option( - "--page_alto", - "-alto", - is_flag=True, - help="If this parameter is set to True, text line image cropping and text extraction are performed using PAGE/ALTO files. Otherwise, the default method for PAGE XML files is used.", -) def linegt_cli( image, dir_in, @@ -70,7 +64,6 @@ def linegt_cli( pref_of_dataset, do_not_mask_with_textline_contour, exclude_vertical_lines, - page_alto, ): assert bool(dir_in) ^ bool(image), "Set --dir-in or --image-filename, not both" if dir_in: @@ -86,147 +79,69 @@ def linegt_cli( dir_xml = os.path.join(dir_xmls, file_name + '.xml') img = cv2.imread(dir_img) - if page_alto: - h, w = img.shape[:2] - - tree = ET.parse(dir_xml) - root = tree.getroot() + total_bb_coordinates = [] - NS = {'alto': root.tag.split('}')[0].strip('{')}#{"alto": "http://www.loc.gov/standards/alto/ns-v4#"} + tree = ET.parse(dir_xml, parser=ET.XMLParser(encoding="utf-8")) + root = tree.getroot() + alltags = [elem.tag for elem in root.iter()] - results = [] - - indexer_textlines = 0 - for line in root.findall(".//alto:TextLine", NS): - string_el = line.find("alto:String", NS) - textline_text = string_el.attrib["CONTENT"] if string_el is not None else None + name_space = alltags[0].split('}')[0] + name_space = name_space.split('{')[1] - polygon_el = line.find("alto:Shape/alto:Polygon", NS) - if polygon_el is None: - continue + region_tags = np.unique([x for x in alltags if x.endswith('TextRegion')]) - points = polygon_el.attrib["POINTS"].split() - coords = [ - (int(points[i]), int(points[i + 1])) - for i in range(0, len(points), 2) - ] - - coords = np.array(coords, dtype=np.int32) - x, y, w, h = cv2.boundingRect(coords) - - - if exclude_vertical_lines and h > 1.4 * w: - img_crop = None - continue - - img_poly_on_img = np.copy(img) + cropped_lines_region_indexer = [] - mask_poly = np.zeros(img.shape) - mask_poly = cv2.fillPoly(mask_poly, pts=[coords], color=(1, 1, 1)) + indexer_text_region = 0 + indexer_textlines = 0 + # FIXME: non recursive, use OCR-D PAGE generateDS API. Or use an existing tool for this purpose altogether + for nn in root.iter(region_tags): + for child_textregion in nn: + if child_textregion.tag.endswith("TextLine"): + for child_textlines in child_textregion: + if child_textlines.tag.endswith("Coords"): + cropped_lines_region_indexer.append(indexer_text_region) + p_h = child_textlines.attrib['points'].split(' ') + textline_coords = np.array([[int(x.split(',')[0]), int(x.split(',')[1])] for x in p_h]) - mask_poly = mask_poly[y : y + h, x : x + w, :] - img_crop = img_poly_on_img[y : y + h, x : x + w, :] - - if not do_not_mask_with_textline_contour: - img_crop[mask_poly == 0] = 255 - - if img_crop.shape[0] == 0 or img_crop.shape[1] == 0: - img_crop = None - continue - - if textline_text and img_crop is not None: - base_name = os.path.join( - dir_out, file_name + '_line_' + str(indexer_textlines) - ) - if pref_of_dataset: - base_name += '_' + pref_of_dataset - if not do_not_mask_with_textline_contour: - base_name += '_masked' - - with open(base_name + '.txt', 'w') as text_file: - text_file.write(textline_text) - cv2.imwrite(base_name + '.png', img_crop) - indexer_textlines += 1 - - - - - - - - - - - - - - - - - else: - total_bb_coordinates = [] - - tree = ET.parse(dir_xml, parser=ET.XMLParser(encoding="utf-8")) - root = tree.getroot() - alltags = [elem.tag for elem in root.iter()] - - name_space = alltags[0].split('}')[0] - name_space = name_space.split('{')[1] - - region_tags = np.unique([x for x in alltags if x.endswith('TextRegion')]) - - cropped_lines_region_indexer = [] - - indexer_text_region = 0 - indexer_textlines = 0 - # FIXME: non recursive, use OCR-D PAGE generateDS API. Or use an existing tool for this purpose altogether - for nn in root.iter(region_tags): - for child_textregion in nn: - if child_textregion.tag.endswith("TextLine"): - for child_textlines in child_textregion: - if child_textlines.tag.endswith("Coords"): - cropped_lines_region_indexer.append(indexer_text_region) - p_h = child_textlines.attrib['points'].split(' ') - textline_coords = np.array([[int(x.split(',')[0]), int(x.split(',')[1])] for x in p_h]) - - x, y, w, h = cv2.boundingRect(textline_coords) - - if exclude_vertical_lines and h > 1.4 * w: - img_crop = None - continue - - total_bb_coordinates.append([x, y, w, h]) - - img_poly_on_img = np.copy(img) - - mask_poly = np.zeros(img.shape) - mask_poly = cv2.fillPoly(mask_poly, pts=[textline_coords], color=(1, 1, 1)) - - mask_poly = mask_poly[y : y + h, x : x + w, :] - img_crop = img_poly_on_img[y : y + h, x : x + w, :] - - if not do_not_mask_with_textline_contour: - img_crop[mask_poly == 0] = 255 - - if img_crop.shape[0] == 0 or img_crop.shape[1] == 0: - img_crop = None - continue + x, y, w, h = cv2.boundingRect(textline_coords) - - if child_textlines.tag.endswith("TextEquiv"): - for cheild_text in child_textlines: - if cheild_text.tag.endswith("Unicode"): - textline_text = cheild_text.text - if textline_text and img_crop is not None: - base_name = os.path.join( - dir_out, file_name + '_line_' + str(indexer_textlines) - ) - if pref_of_dataset: - base_name += '_' + pref_of_dataset - if not do_not_mask_with_textline_contour: - base_name += '_masked' + if exclude_vertical_lines and h > 1.4 * w: + img_crop = None + continue - with open(base_name + '.txt', 'w') as text_file: - text_file.write(textline_text) - cv2.imwrite(base_name + '.png', img_crop) - indexer_textlines += 1 + total_bb_coordinates.append([x, y, w, h]) + + img_poly_on_img = np.copy(img) + + mask_poly = np.zeros(img.shape) + mask_poly = cv2.fillPoly(mask_poly, pts=[textline_coords], color=(1, 1, 1)) + + mask_poly = mask_poly[y : y + h, x : x + w, :] + img_crop = img_poly_on_img[y : y + h, x : x + w, :] + + if not do_not_mask_with_textline_contour: + img_crop[mask_poly == 0] = 255 + + if img_crop.shape[0] == 0 or img_crop.shape[1] == 0: + img_crop = None + continue + + + if child_textlines.tag.endswith("TextEquiv"): + for cheild_text in child_textlines: + if cheild_text.tag.endswith("Unicode"): + textline_text = cheild_text.text + if textline_text and img_crop is not None: + base_name = os.path.join( + dir_out, file_name + '_line_' + str(indexer_textlines) + ) + if pref_of_dataset: + base_name += '_' + pref_of_dataset + if not do_not_mask_with_textline_contour: + base_name += '_masked' + + with open(base_name + '.txt', 'w') as text_file: + text_file.write(textline_text) + cv2.imwrite(base_name + '.png', img_crop) + indexer_textlines += 1 diff --git a/src/eynollah/training/generate_gt_for_training.py b/src/eynollah/training/generate_gt_for_training.py index 899675e..1e820f0 100644 --- a/src/eynollah/training/generate_gt_for_training.py +++ b/src/eynollah/training/generate_gt_for_training.py @@ -73,14 +73,8 @@ def main(): is_flag=True, help="if this parameter set to true, generated labels and in the case of provided org images cropping will be imposed and cropped labels and images will be written in output directories.", ) -@click.option( - "--page_alto", - "-alto", - is_flag=True, - help="If this parameter is set to True, textline label generation is performed using PAGE/ALTO files. Otherwise, the default method for PAGE XML files is used.", -) -def pagexml2label(dir_xml,dir_out,type_output,config, printspace, dir_images, dir_out_images, page_alto): +def pagexml2label(dir_xml,dir_out,type_output,config, printspace, dir_images, dir_out_images): if config: with open(config) as f: config_params = json.load(f) @@ -88,7 +82,7 @@ def pagexml2label(dir_xml,dir_out,type_output,config, printspace, dir_images, di print("passed") config_params = None gt_list = get_content_of_dir(dir_xml) - get_images_of_ground_truth(gt_list,dir_xml,dir_out,type_output, config, config_params, printspace, dir_images, dir_out_images, page_alto) + get_images_of_ground_truth(gt_list,dir_xml,dir_out,type_output, config, config_params, printspace, dir_images, dir_out_images) @main.command() @click.option( diff --git a/src/eynollah/training/gt_gen_utils.py b/src/eynollah/training/gt_gen_utils.py index 717865f..1e5f51a 100644 --- a/src/eynollah/training/gt_gen_utils.py +++ b/src/eynollah/training/gt_gen_utils.py @@ -686,7 +686,7 @@ def get_layout_contours_for_visualization(xml_file): co_noise.append(np.array(c_t_in)) return co_text, co_graphic, co_sep, co_img, co_table, co_map, co_music, co_noise, y_len, x_len -def get_images_of_ground_truth(gt_list, dir_in, output_dir, output_type, config_file, config_params, printspace, dir_images, dir_out_images, page_alto=False): +def get_images_of_ground_truth(gt_list, dir_in, output_dir, output_type, config_file, config_params, printspace, dir_images, dir_out_images): """ Reading the page xml files and write the ground truth images into given output directory. """ @@ -699,94 +699,81 @@ def get_images_of_ground_truth(gt_list, dir_in, output_dir, output_type, config_ print(gt_list[index]) try: - if page_alto: - tree = ET.parse(dir_in+'/'+gt_list[index]) - root = tree.getroot() - - NS = {'alto': root.tag.split('}')[0].strip('{')}#{"alto": "http://www.loc.gov/standards/alto/ns-v4#"} - x_len, y_len = 0, 0 - - page = root.find('.//alto:Page', NS) - - x_len = int( page.get("WIDTH") ) - y_len = int( page.get("HEIGHT") ) - - else: - tree1 = ET.parse(dir_in+'/'+gt_list[index], parser = ET.XMLParser(encoding='utf-8')) - root1=tree1.getroot() - alltags=[elem.tag for elem in root1.iter()] - link=alltags[0].split('}')[0]+'}' - - - x_len, y_len = 0, 0 - for jj in root1.iter(link+'Page'): - y_len=int(jj.attrib['imageHeight']) - x_len=int(jj.attrib['imageWidth']) - - if 'columns_width' in list(config_params.keys()): - columns_width_dict = config_params['columns_width'] - metadata_element = root1.find(link+'Metadata') - num_col = None - for child in metadata_element: - tag2 = child.tag - if tag2.endswith('}Comments') or tag2.endswith('}comments'): - text_comments = child.text - num_col = int(text_comments.split('num_col')[1]) - - if num_col: - x_new = columns_width_dict[str(num_col)] - y_new = int ( x_new * (y_len / float(x_len)) ) - - if printspace or "printspace_as_class_in_layout" in list(config_params.keys()): - region_tags = np.unique([x for x in alltags if x.endswith('PrintSpace') or x.endswith('Border')]) - co_use_case = [] - - for tag in region_tags: - tag_endings = ['}PrintSpace','}Border'] - - if tag.endswith(tag_endings[0]) or tag.endswith(tag_endings[1]): - for nn in root1.iter(tag): - c_t_in = [] - sumi = 0 - for vv in nn.iter(): - # check the format of coords - if vv.tag == link + 'Coords': - coords = bool(vv.attrib) - if coords: - p_h = vv.attrib['points'].split(' ') - c_t_in.append( - np.array([[int(x.split(',')[0]), int(x.split(',')[1])] for x in p_h])) - break - else: - pass - - if vv.tag == link + 'Point': - c_t_in.append([int(float(vv.attrib['x'])), int(float(vv.attrib['y']))]) - sumi += 1 - elif vv.tag != link + 'Point' and sumi >= 1: - break - co_use_case.append(np.array(c_t_in)) + tree1 = ET.parse(dir_in+'/'+gt_list[index], parser = ET.XMLParser(encoding='utf-8')) + root1=tree1.getroot() + alltags=[elem.tag for elem in root1.iter()] + link=alltags[0].split('}')[0]+'}' - img = np.zeros((y_len, x_len, 3)) + + x_len, y_len = 0, 0 + for jj in root1.iter(link+'Page'): + y_len=int(jj.attrib['imageHeight']) + x_len=int(jj.attrib['imageWidth']) + + if 'columns_width' in list(config_params.keys()): + columns_width_dict = config_params['columns_width'] + metadata_element = root1.find(link+'Metadata') + num_col = None + for child in metadata_element: + tag2 = child.tag + if tag2.endswith('}Comments') or tag2.endswith('}comments'): + text_comments = child.text + num_col = int(text_comments.split('num_col')[1]) - img_poly = cv2.fillPoly(img, pts=co_use_case, color=(1, 1, 1)) - - img_poly = img_poly.astype(np.uint8) - - imgray = cv2.cvtColor(img_poly, cv2.COLOR_BGR2GRAY) - _, thresh = cv2.threshold(imgray, 0, 255, 0) + if num_col: + x_new = columns_width_dict[str(num_col)] + y_new = int ( x_new * (y_len / float(x_len)) ) + + if printspace or "printspace_as_class_in_layout" in list(config_params.keys()): + region_tags = np.unique([x for x in alltags if x.endswith('PrintSpace') or x.endswith('Border')]) + co_use_case = [] - contours, _ = cv2.findContours(thresh, cv2.RETR_TREE, cv2.CHAIN_APPROX_SIMPLE) - - cnt_size = np.array([cv2.contourArea(contours[j]) for j in range(len(contours))]) - - try: - cnt = contours[np.argmax(cnt_size)] - x, y, w, h = cv2.boundingRect(cnt) - except: - x, y , w, h = 0, 0, x_len, y_len - - bb_xywh = [x, y, w, h] + for tag in region_tags: + tag_endings = ['}PrintSpace','}Border'] + + if tag.endswith(tag_endings[0]) or tag.endswith(tag_endings[1]): + for nn in root1.iter(tag): + c_t_in = [] + sumi = 0 + for vv in nn.iter(): + # check the format of coords + if vv.tag == link + 'Coords': + coords = bool(vv.attrib) + if coords: + p_h = vv.attrib['points'].split(' ') + c_t_in.append( + np.array([[int(x.split(',')[0]), int(x.split(',')[1])] for x in p_h])) + break + else: + pass + + if vv.tag == link + 'Point': + c_t_in.append([int(float(vv.attrib['x'])), int(float(vv.attrib['y']))]) + sumi += 1 + elif vv.tag != link + 'Point' and sumi >= 1: + break + co_use_case.append(np.array(c_t_in)) + + img = np.zeros((y_len, x_len, 3)) + + img_poly = cv2.fillPoly(img, pts=co_use_case, color=(1, 1, 1)) + + img_poly = img_poly.astype(np.uint8) + + imgray = cv2.cvtColor(img_poly, cv2.COLOR_BGR2GRAY) + _, thresh = cv2.threshold(imgray, 0, 255, 0) + + contours, _ = cv2.findContours(thresh, cv2.RETR_TREE, cv2.CHAIN_APPROX_SIMPLE) + + cnt_size = np.array([cv2.contourArea(contours[j]) for j in range(len(contours))]) + + try: + cnt = contours[np.argmax(cnt_size)] + x, y, w, h = cv2.boundingRect(cnt) + except: + x, y , w, h = 0, 0, x_len, y_len + + bb_xywh = [x, y, w, h] if config_file and (config_params['use_case']=='textline' or config_params['use_case']=='word' or config_params['use_case']=='glyph' or config_params['use_case']=='printspace'): @@ -797,67 +784,49 @@ def get_images_of_ground_truth(gt_list, dir_in, output_dir, output_type, config_ textline_rgb_color = (255, 0, 0) - if page_alto: - co_use_case = [] - for line in root.findall(".//alto:TextLine", NS): - string_el = line.find("alto:String", NS) - textline_text = string_el.attrib["CONTENT"] if string_el is not None else None + if config_params['use_case']=='textline': + region_tags = np.unique([x for x in alltags if x.endswith('TextLine')]) + elif config_params['use_case']=='word': + region_tags = np.unique([x for x in alltags if x.endswith('Word')]) + elif config_params['use_case']=='glyph': + region_tags = np.unique([x for x in alltags if x.endswith('Glyph')]) + elif config_params['use_case']=='printspace': + region_tags = np.unique([x for x in alltags if x.endswith('PrintSpace')]) + + co_use_case = [] - polygon_el = line.find("alto:Shape/alto:Polygon", NS) - if polygon_el is None: - continue - - points = polygon_el.attrib["POINTS"].split() - coords = [ - (int(points[i]), int(points[i + 1])) - for i in range(0, len(points), 2) - ] - - co_use_case.append( np.array(coords, dtype=np.int32) ) - else: + for tag in region_tags: if config_params['use_case']=='textline': - region_tags = np.unique([x for x in alltags if x.endswith('TextLine')]) + tag_endings = ['}TextLine','}textline'] elif config_params['use_case']=='word': - region_tags = np.unique([x for x in alltags if x.endswith('Word')]) + tag_endings = ['}Word','}word'] elif config_params['use_case']=='glyph': - region_tags = np.unique([x for x in alltags if x.endswith('Glyph')]) + tag_endings = ['}Glyph','}glyph'] elif config_params['use_case']=='printspace': - region_tags = np.unique([x for x in alltags if x.endswith('PrintSpace')]) + tag_endings = ['}PrintSpace','}printspace'] - co_use_case = [] - - for tag in region_tags: - if config_params['use_case']=='textline': - tag_endings = ['}TextLine','}textline'] - elif config_params['use_case']=='word': - tag_endings = ['}Word','}word'] - elif config_params['use_case']=='glyph': - tag_endings = ['}Glyph','}glyph'] - elif config_params['use_case']=='printspace': - tag_endings = ['}PrintSpace','}printspace'] - - if tag.endswith(tag_endings[0]) or tag.endswith(tag_endings[1]): - for nn in root1.iter(tag): - c_t_in = [] - sumi = 0 - for vv in nn.iter(): - # check the format of coords - if vv.tag == link + 'Coords': - coords = bool(vv.attrib) - if coords: - p_h = vv.attrib['points'].split(' ') - c_t_in.append( - np.array([[int(x.split(',')[0]), int(x.split(',')[1])] for x in p_h])) - break - else: - pass - - if vv.tag == link + 'Point': - c_t_in.append([int(float(vv.attrib['x'])), int(float(vv.attrib['y']))]) - sumi += 1 - elif vv.tag != link + 'Point' and sumi >= 1: + if tag.endswith(tag_endings[0]) or tag.endswith(tag_endings[1]): + for nn in root1.iter(tag): + c_t_in = [] + sumi = 0 + for vv in nn.iter(): + # check the format of coords + if vv.tag == link + 'Coords': + coords = bool(vv.attrib) + if coords: + p_h = vv.attrib['points'].split(' ') + c_t_in.append( + np.array([[int(x.split(',')[0]), int(x.split(',')[1])] for x in p_h])) break - co_use_case.append(np.array(c_t_in)) + else: + pass + + if vv.tag == link + 'Point': + c_t_in.append([int(float(vv.attrib['x'])), int(float(vv.attrib['y']))]) + sumi += 1 + elif vv.tag != link + 'Point' and sumi >= 1: + break + co_use_case.append(np.array(c_t_in)) if "artificial_class_label" in keys: