From 8f7b5ed649c0f5bead60f5fd71a09981fd4a7195 Mon Sep 17 00:00:00 2001 From: angelgarcia Date: Mon, 2 Sep 2024 08:50:05 +0000 Subject: [PATCH 1/8] Add initial version of PDF processing script --- app.py | 288 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 288 insertions(+) create mode 100644 app.py diff --git a/app.py b/app.py new file mode 100644 index 0000000..c354131 --- /dev/null +++ b/app.py @@ -0,0 +1,288 @@ +# refactoring pdf_extract.py + +import os +import cv2 +import json +import yaml +import time +import pytz +import datetime +import argparse +import shutil +import torch +import numpy as np +import gc + +from paddleocr import draw_ocr +from PIL import Image, ImageDraw, ImageFont +from torchvision import transforms +from torch.utils.data import Dataset, DataLoader +from ultralytics import YOLO +from unimernet.common.config import Config +import unimernet.tasks as tasks +from unimernet.processors import load_processor +from struct_eqtable import build_model + +from modules.latex2png import tex2pil, zhtext2pil +from modules.extract_pdf import load_pdf_fitz +from modules.layoutlmv3.model_init import Layoutlmv3_Predictor +from modules.self_modify import ModifiedPaddleOCR +from modules.post_process import get_croped_image, latex_rm_whitespace + + +def mfd_model_init(weight): + mfd_model = YOLO(weight) + return mfd_model + + +def mfr_model_init(weight_dir, device='cpu'): + args = argparse.Namespace(cfg_path="modules/UniMERNet/configs/demo.yaml", options=None) + cfg = Config(args) + cfg.config.model.pretrained = os.path.join(weight_dir, "pytorch_model.bin") + cfg.config.model.model_config.model_name = weight_dir + cfg.config.model.tokenizer_config.path = weight_dir + task = tasks.setup_task(cfg) + model = task.build_model(cfg) + model = model.to(device) + vis_processor = load_processor('formula_image_eval', cfg.config.datasets.formula_rec_eval.vis_processor.eval) + return model, vis_processor + + +def layout_model_init(weight): + model = Layoutlmv3_Predictor(weight) + return model + + +def tr_model_init(weight, max_time, device='cuda'): + tr_model = build_model(weight, max_new_tokens=4096, max_time=max_time) + if device == 'cuda': + tr_model = tr_model.cuda() + return tr_model + + +class MathDataset(Dataset): + def __init__(self, image_paths, transform=None): + self.image_paths = image_paths + self.transform = transform + + def __len__(self): + return len(self.image_paths) + + def __getitem__(self, idx): + # if not pil image, then convert to pil image + if isinstance(self.image_paths[idx], str): + raw_image = Image.open(self.image_paths[idx]) + else: + raw_image = self.image_paths[idx] + if self.transform: + image = self.transform(raw_image) + return image + + +if __name__ == '__main__': + parser = argparse.ArgumentParser() + parser.add_argument('--pdf', type=str) + parser.add_argument('--output', type=str, default="output") + parser.add_argument('--batch-size', type=int, default=128) + parser.add_argument('--vis', action='store_true') + parser.add_argument('--render', action='store_true') + parser.add_argument('--timezone', type=str, default="Asia/Shanghai") + args = parser.parse_args() + print(args) + + tz = pytz.timezone(args.timezone) + now = datetime.datetime.now(tz) + print(now.strftime('%Y-%m-%d %H:%M:%S')) + print('Started!') + + ## ======== model init ========## + with open('configs/model_configs.yaml') as f: + model_configs = yaml.load(f, Loader=yaml.FullLoader) + img_size = model_configs['model_args']['img_size'] + conf_thres = model_configs['model_args']['conf_thres'] + iou_thres = model_configs['model_args']['iou_thres'] + device = model_configs['model_args']['device'] + dpi = model_configs['model_args']['pdf_dpi'] + mfd_model = mfd_model_init(model_configs['model_args']['mfd_weight']) + mfr_model, mfr_vis_processors = mfr_model_init(model_configs['model_args']['mfr_weight'], device=device) + mfr_transform = transforms.Compose([mfr_vis_processors, ]) + tr_model = tr_model_init(model_configs['model_args']['tr_weight'], + max_time=model_configs['model_args']['table_max_time'], device=device) + layout_model = layout_model_init(model_configs['model_args']['layout_weight']) + ocr_model = ModifiedPaddleOCR(show_log=True) + print(now.strftime('%Y-%m-%d %H:%M:%S')) + print('Model init done!') + ## ======== model init ========## + + start = time.time() + if os.path.isdir(args.pdf): + all_pdfs = [os.path.join(args.pdf, name) for name in os.listdir(args.pdf)] + else: + all_pdfs = [args.pdf] + print("total files:", len(all_pdfs)) + for idx, single_pdf in enumerate(all_pdfs): + try: + img_list = load_pdf_fitz(single_pdf, dpi=dpi) + except: + img_list = None + print("unexpected pdf file:", single_pdf) + if img_list is None: + continue + print("pdf index:", idx, "pages:", len(img_list)) + # layout detection and formula detection + doc_layout_result = [] + latex_filling_list = [] + mf_image_list = [] + for idx, image in enumerate(img_list): + img_H, img_W = image.shape[0], image.shape[1] + layout_res = layout_model(image, ignore_catids=[]) + mfd_res = mfd_model.predict(image, imgsz=img_size, conf=conf_thres, iou=iou_thres, verbose=True)[0] + for xyxy, conf, cla in zip(mfd_res.boxes.xyxy.cpu(), mfd_res.boxes.conf.cpu(), mfd_res.boxes.cls.cpu()): + xmin, ymin, xmax, ymax = [int(p.item()) for p in xyxy] + new_item = { + 'category_id': 13 + int(cla.item()), + 'poly': [xmin, ymin, xmax, ymin, xmax, ymax, xmin, ymax], + 'score': round(float(conf.item()), 2), + 'latex': '', + } + layout_res['layout_dets'].append(new_item) + latex_filling_list.append(new_item) + bbox_img = get_croped_image(Image.fromarray(image), [xmin, ymin, xmax, ymax]) + mf_image_list.append(bbox_img) + + layout_res['page_info'] = dict( + page_no=idx, + height=img_H, + width=img_W + ) + doc_layout_result.append(layout_res) + + del mfd_res + torch.cuda.empty_cache() + gc.collect() + + # Formula recognition, collect all formula images in whole pdf file, then batch infer them. + a = time.time() + dataset = MathDataset(mf_image_list, transform=mfr_transform) + dataloader = DataLoader(dataset, batch_size=args.batch_size, num_workers=32) + mfr_res = [] + for imgs in dataloader: + imgs = imgs.to(device) + output = mfr_model.generate({'image': imgs}) + mfr_res.extend(output['pred_str']) + for res, latex in zip(latex_filling_list, mfr_res): + res['latex'] = latex_rm_whitespace(latex) + b = time.time() + print("formula nums:", len(mf_image_list), "mfr time:", round(b - a, 2)) + + # ocr and table recognition + for idx, image in enumerate(img_list): + pil_img = Image.fromarray(cv2.cvtColor(image, cv2.COLOR_RGB2BGR)) + single_page_res = doc_layout_result[idx]['layout_dets'] + single_page_mfdetrec_res = [] + for res in single_page_res: + if int(res['category_id']) in [13, 14]: + xmin, ymin = int(res['poly'][0]), int(res['poly'][1]) + xmax, ymax = int(res['poly'][4]), int(res['poly'][5]) + single_page_mfdetrec_res.append({ + "bbox": [xmin, ymin, xmax, ymax], + }) + for res in single_page_res: + if int(res['category_id']) in [0, 1, 2, 4, 6, 7]: # categories that need to do ocr + xmin, ymin = int(res['poly'][0]), int(res['poly'][1]) + xmax, ymax = int(res['poly'][4]), int(res['poly'][5]) + crop_box = [xmin, ymin, xmax, ymax] + cropped_img = Image.new('RGB', pil_img.size, 'white') + cropped_img.paste(pil_img.crop(crop_box), crop_box) + cropped_img = cv2.cvtColor(np.asarray(cropped_img), cv2.COLOR_RGB2BGR) + ocr_res = ocr_model.ocr(cropped_img, mfd_res=single_page_mfdetrec_res)[0] + if ocr_res: + for box_ocr_res in ocr_res: + p1, p2, p3, p4 = box_ocr_res[0] + text, score = box_ocr_res[1] + doc_layout_result[idx]['layout_dets'].append({ + 'category_id': 15, + 'poly': p1 + p2 + p3 + p4, + 'score': round(score, 2), + 'text': text, + }) + elif int(res['category_id']) == 5: # do table recognition + xmin, ymin = int(res['poly'][0]), int(res['poly'][1]) + xmax, ymax = int(res['poly'][4]), int(res['poly'][5]) + crop_box = [xmin, ymin, xmax, ymax] + cropped_img = pil_img.convert("RGB").crop(crop_box) + start = time.time() + with torch.no_grad(): + output = tr_model(cropped_img) + end = time.time() + if (end - start) > model_configs['model_args']['table_max_time']: + res["timeout"] = True + res["latex"] = output[0] + + output_dir = args.output + os.makedirs(output_dir, exist_ok=True) + basename = os.path.basename(single_pdf)[0:-4] + with open(os.path.join(output_dir, f'{basename}.json'), 'w') as f: + json.dump(doc_layout_result, f) + + if args.vis: + color_palette = [ + (255, 64, 255), (255, 255, 0), (0, 255, 255), (255, 215, 135), (215, 0, 95), (100, 0, 48), (0, 175, 0), + (95, 0, 95), (175, 95, 0), (95, 95, 0), + (95, 95, 255), (95, 175, 135), (215, 95, 0), (0, 0, 255), (0, 255, 0), (255, 0, 0), (0, 95, 215), + (0, 0, 0), (0, 0, 0), (0, 0, 0) + ] + id2names = ["title", "plain_text", "abandon", "figure", "figure_caption", "table", "table_caption", + "table_footnote", + "isolate_formula", "formula_caption", " ", " ", " ", "inline_formula", "isolated_formula", + "ocr_text"] + vis_pdf_result = [] + for idx, image in enumerate(img_list): + single_page_res = doc_layout_result[idx]['layout_dets'] + vis_img = Image.new('RGB', Image.fromarray(image).size, 'white') if args.render else Image.fromarray( + cv2.cvtColor(image, cv2.COLOR_RGB2BGR)) + draw = ImageDraw.Draw(vis_img) + for res in single_page_res: + label = int(res['category_id']) + if label > 15: # categories that do not need visualize + continue + label_name = id2names[label] + x_min, y_min = int(res['poly'][0]), int(res['poly'][1]) + x_max, y_max = int(res['poly'][4]), int(res['poly'][5]) + if args.render and label in [13, 14, 15]: + try: + if label in [13, 14]: # render formula + window_img = tex2pil(res['latex'])[0] + else: + if True: # render chinese + window_img = zhtext2pil(res['text']) + else: # render english + window_img = tex2pil([res['text']], tex_type="text")[0] + ratio = min((x_max - x_min) / window_img.width, (y_max - y_min) / window_img.height) - 0.05 + window_img = window_img.resize( + (int(window_img.width * ratio), int(window_img.height * ratio))) + vis_img.paste(window_img, (int(x_min + (x_max - x_min - window_img.width) / 2), + int(y_min + (y_max - y_min - window_img.height) / 2))) + except Exception as e: + print(f"got exception on {text}, error info: {e}") + draw.rectangle([x_min, y_min, x_max, y_max], fill=None, outline=color_palette[label], width=1) + fontText = ImageFont.truetype("assets/fonts/simhei.ttf", 15, encoding="utf-8") + draw.text((x_min, y_min), label_name, color_palette[label], font=fontText) + + width, height = vis_img.size + width, height = int(0.75 * width), int(0.75 * height) + vis_img = vis_img.resize((width, height)) + vis_pdf_result.append(vis_img) + + first_page = vis_pdf_result.pop(0) + first_page.save(os.path.join(output_dir, f'{basename}.pdf'), 'PDF', resolution=100, save_all=True, + append_images=vis_pdf_result) + try: + shutil.rmtree('./temp') + except: + pass + + now = datetime.datetime.now(tz) + end = time.time() + print(now.strftime('%Y-%m-%d %H:%M:%S')) + print('Finished! time cost:', int(end - start), 's') \ No newline at end of file From ca84c57d29d497f950659c4db1afa650feb27148 Mon Sep 17 00:00:00 2001 From: angelgarcia Date: Mon, 2 Sep 2024 10:33:11 +0000 Subject: [PATCH 2/8] Introduce logging and refactor PDF processing Added detailed logging configurations to improve visibility and debugging. Refactored PDF handling and processing into separate utility functions for better code organization and maintainability. --- app.py | 82 +++++++++++++++++++------------------ utils/__init__.py | 0 utils/logging_config.py | 83 +++++++++++++++++++++++++++++++++++++ utils/pdf_tools.py | 91 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 216 insertions(+), 40 deletions(-) create mode 100644 utils/__init__.py create mode 100644 utils/logging_config.py create mode 100644 utils/pdf_tools.py diff --git a/app.py b/app.py index c354131..2ea8ca9 100644 --- a/app.py +++ b/app.py @@ -13,6 +13,7 @@ import numpy as np import gc + from paddleocr import draw_ocr from PIL import Image, ImageDraw, ImageFont from torchvision import transforms @@ -29,6 +30,12 @@ from modules.self_modify import ModifiedPaddleOCR from modules.post_process import get_croped_image, latex_rm_whitespace +from utils.pdf_tools import check_pdf, process_all_pdfs +from utils.logging_config import setup_logging + +# Apply the logging configuration +logger = setup_logging('app') + def mfd_model_init(weight): mfd_model = YOLO(weight) @@ -80,29 +87,35 @@ def __getitem__(self, idx): if __name__ == '__main__': - parser = argparse.ArgumentParser() - parser.add_argument('--pdf', type=str) - parser.add_argument('--output', type=str, default="output") - parser.add_argument('--batch-size', type=int, default=128) - parser.add_argument('--vis', action='store_true') - parser.add_argument('--render', action='store_true') - parser.add_argument('--timezone', type=str, default="Asia/Shanghai") - args = parser.parse_args() - print(args) - - tz = pytz.timezone(args.timezone) - now = datetime.datetime.now(tz) - print(now.strftime('%Y-%m-%d %H:%M:%S')) - print('Started!') + # parser = argparse.ArgumentParser() + # parser.add_argument('--pdf', type=str) + # parser.add_argument('--output', type=str, default="output") + # parser.add_argument('--batch-size', type=int, default=128) + # parser.add_argument('--vis', action='store_true') + # parser.add_argument('--render', action='store_true') + # parser.add_argument('--timezone', type=str, default="Asia/Shanghai") + # args = parser.parse_args() + # print(args) + + # Params + pdf_path: str = '1706.03762.pdf' + output_dir: str = 'output' + batch_size: int = 128 + vis: bool = False + render: bool = False + logger.info('Started!') + start_0 = time.time() ## ======== model init ========## with open('configs/model_configs.yaml') as f: model_configs = yaml.load(f, Loader=yaml.FullLoader) + img_size = model_configs['model_args']['img_size'] conf_thres = model_configs['model_args']['conf_thres'] iou_thres = model_configs['model_args']['iou_thres'] device = model_configs['model_args']['device'] dpi = model_configs['model_args']['pdf_dpi'] + mfd_model = mfd_model_init(model_configs['model_args']['mfd_weight']) mfr_model, mfr_vis_processors = mfr_model_init(model_configs['model_args']['mfr_weight'], device=device) mfr_transform = transforms.Compose([mfr_vis_processors, ]) @@ -110,26 +123,16 @@ def __getitem__(self, idx): max_time=model_configs['model_args']['table_max_time'], device=device) layout_model = layout_model_init(model_configs['model_args']['layout_weight']) ocr_model = ModifiedPaddleOCR(show_log=True) - print(now.strftime('%Y-%m-%d %H:%M:%S')) - print('Model init done!') + + logger.info(f'Model init done in {int(time.time() - start_0)}s!') ## ======== model init ========## - start = time.time() - if os.path.isdir(args.pdf): - all_pdfs = [os.path.join(args.pdf, name) for name in os.listdir(args.pdf)] - else: - all_pdfs = [args.pdf] - print("total files:", len(all_pdfs)) - for idx, single_pdf in enumerate(all_pdfs): - try: - img_list = load_pdf_fitz(single_pdf, dpi=dpi) - except: - img_list = None - print("unexpected pdf file:", single_pdf) - if img_list is None: - continue - print("pdf index:", idx, "pages:", len(img_list)) + start_0 = time.time() + all_pdfs = check_pdf(pdf_path) + for idx, single_pdf, img_list in process_all_pdfs(all_pdfs, dpi): + # layout detection and formula detection + logger.debug('layout detection and formula detection') doc_layout_result = [] latex_filling_list = [] mf_image_list = [] @@ -163,8 +166,9 @@ def __getitem__(self, idx): # Formula recognition, collect all formula images in whole pdf file, then batch infer them. a = time.time() + logger.debug('Formula recognition') dataset = MathDataset(mf_image_list, transform=mfr_transform) - dataloader = DataLoader(dataset, batch_size=args.batch_size, num_workers=32) + dataloader = DataLoader(dataset, batch_size=batch_size, num_workers=32) mfr_res = [] for imgs in dataloader: imgs = imgs.to(device) @@ -176,6 +180,7 @@ def __getitem__(self, idx): print("formula nums:", len(mf_image_list), "mfr time:", round(b - a, 2)) # ocr and table recognition + logger.debug('ocr and table recognition') for idx, image in enumerate(img_list): pil_img = Image.fromarray(cv2.cvtColor(image, cv2.COLOR_RGB2BGR)) single_page_res = doc_layout_result[idx]['layout_dets'] @@ -219,13 +224,13 @@ def __getitem__(self, idx): res["timeout"] = True res["latex"] = output[0] - output_dir = args.output os.makedirs(output_dir, exist_ok=True) basename = os.path.basename(single_pdf)[0:-4] + logger.debug(f'Save file: {basename}.json') with open(os.path.join(output_dir, f'{basename}.json'), 'w') as f: json.dump(doc_layout_result, f) - if args.vis: + if vis: color_palette = [ (255, 64, 255), (255, 255, 0), (0, 255, 255), (255, 215, 135), (215, 0, 95), (100, 0, 48), (0, 175, 0), (95, 0, 95), (175, 95, 0), (95, 95, 0), @@ -239,7 +244,7 @@ def __getitem__(self, idx): vis_pdf_result = [] for idx, image in enumerate(img_list): single_page_res = doc_layout_result[idx]['layout_dets'] - vis_img = Image.new('RGB', Image.fromarray(image).size, 'white') if args.render else Image.fromarray( + vis_img = Image.new('RGB', Image.fromarray(image).size, 'white') if render else Image.fromarray( cv2.cvtColor(image, cv2.COLOR_RGB2BGR)) draw = ImageDraw.Draw(vis_img) for res in single_page_res: @@ -249,7 +254,7 @@ def __getitem__(self, idx): label_name = id2names[label] x_min, y_min = int(res['poly'][0]), int(res['poly'][1]) x_max, y_max = int(res['poly'][4]), int(res['poly'][5]) - if args.render and label in [13, 14, 15]: + if render and label in [13, 14, 15]: try: if label in [13, 14]: # render formula window_img = tex2pil(res['latex'])[0] @@ -282,7 +287,4 @@ def __getitem__(self, idx): except: pass - now = datetime.datetime.now(tz) - end = time.time() - print(now.strftime('%Y-%m-%d %H:%M:%S')) - print('Finished! time cost:', int(end - start), 's') \ No newline at end of file + logger.info(f'Finished! time cost: {int(time.time() - start_0)} s') \ No newline at end of file diff --git a/utils/__init__.py b/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/utils/logging_config.py b/utils/logging_config.py new file mode 100644 index 0000000..ad5d918 --- /dev/null +++ b/utils/logging_config.py @@ -0,0 +1,83 @@ +import os +import pytz +import logging +import logging.config +from datetime import datetime + +# Define the absolute path for the log file +log_directory = os.path.dirname(os.path.abspath(__file__)) +# TODO: Define a suitable log_file_path +log_file_path = os.path.join(log_directory, '../app_logs.log') + + +class CustomFormatter(logging.Formatter): + def __init__(self, fmt=None, datefmt=None, tz=None): + super().__init__(fmt=fmt, datefmt=datefmt) + self.tz = tz + + def formatTime(self, record, datefmt=None): + dt = datetime.fromtimestamp(record.created, self.tz) + if datefmt: + s = dt.strftime(datefmt) + else: + try: + s = dt.isoformat(timespec='milliseconds') + except TypeError: + s = dt.isoformat() + return s + +# TODO: Add to config file +TIMEZONE: str = 'Europe/Madrid'# "Asia/Shanghai" +timezone = pytz.timezone(TIMEZONE) # Specify your time zone here + + +# Basic logging configuration +LOGGING_CONFIG = { + 'version': 1, + 'disable_existing_loggers': False, + 'formatters': { + 'standard': { + '()': CustomFormatter, + 'format': '%(asctime)s - %(name)s - [%(levelname)s] - %(message)s', + 'datefmt': '%Y-%m-%d %H:%M:%S', + 'tz': timezone + }, + }, + 'handlers': { + 'console': { + 'level': 'DEBUG', + 'class': 'logging.StreamHandler', + 'formatter': 'standard' + }, + 'file': { + 'level': 'INFO', + 'class': 'logging.FileHandler', + 'filename': log_file_path, + 'formatter': 'standard' + }, + }, + 'loggers': { + '': { # Logger root + 'handlers': ['console', 'file'], + 'level': 'DEBUG', + 'propagate': True + }, + '__name__': { + 'handlers': ['console'], + 'level': 'INFO', + 'propagate': False + } + } +} + +def setup_logging(name: str = '__main__'): + """Configures logging for the entire library.""" + logging.config.dictConfig(LOGGING_CONFIG) + # Get the logger for this specific module + logger = logging.getLogger(name) + return logger + +# Call configuration immediately +# setup_logging() + + diff --git a/utils/pdf_tools.py b/utils/pdf_tools.py new file mode 100644 index 0000000..d99baf4 --- /dev/null +++ b/utils/pdf_tools.py @@ -0,0 +1,91 @@ +import os + +from modules.extract_pdf import load_pdf_fitz + +from .logging_config import setup_logging + +# Apply the logging configuration +logger = setup_logging('pdf_tools') + + +def check_pdf(pdf_path: str): + """ + Checks if the given path is a directory or a single PDF file. + If it is a directory, it retrieves all the PDF files within the directory. + Otherwise, it treats the path as a single PDF file. + + :param pdf_path: The path to the directory or PDF file. + :type pdf_path: str + :returns: A list of PDF file paths. + :rtype: list[str] + """ + if os.path.isdir(pdf_path): + all_pdfs = [os.path.join(pdf_path, name) for name in os.listdir(pdf_path)] + else: + all_pdfs = [pdf_path] + logger.info(f"Total files: {len(all_pdfs)}") + return all_pdfs + + +def get_images(single_pdf: str, dpi: int = 200) -> list | None: + """ + This function retrieves a list of images from a given PDF file. + It uses the `load_pdf_fitz()` function to load the PDF and convert its contents into images. + + Parameters: + - `single_pdf` (str): The path to the PDF file. + - `dpi` (int): The resolution at which the PDF should be converted to images. Default is 200. + + Returns: + - list or None: A list of images if the conversion was successful, otherwise None. + + Raises: + - Any exceptions raised by the `load_pdf_fitz()` function are caught and logged, and the function returns None. + """ + try: + img_list = load_pdf_fitz(single_pdf, dpi=dpi) + except Exception as e: + logger.error(f"Unexpected error with PDF file '{single_pdf}': {e}") + return None + return img_list + + +def process_all_pdfs(all_pdfs: list, dpi: int = 200): + """ + Processes a list of PDF files and yields information about each PDF file. + + Args: + all_pdfs (list): A list of paths to PDF files. + dpi (int, optional): DPI (dots per inch) value for converting PDF to images. Default is 200. + + Yields: + Tuple[int, str, List]: A tuple containing the following information: + - PDF index (int): Index of the PDF file in the list. + - PDF path (str): Path to the PDF file. + - PDF images (List): A list of images extracted from the PDF file. + + Notes: + - If an error occurs while processing a PDF file, it will be skipped and the next PDF file will be processed. + - The logger will output information about the PDF index and the number of pages in each PDF file. + + Example: + >>> pdfs = [ + ... 'path/to/file1.pdf', + ... 'path/to/file2.pdf', + ... ] + >>> for idx, pdf, images in process_all_pdfs(pdfs): + ... print(f"PDF index: {idx}, PDF path: {pdf}, Number of images: {len(images)}") + """ + for idx, single_pdf in enumerate(all_pdfs): + img_list = get_images(single_pdf, dpi) + + if img_list is None: + continue + + logger.info(f"PDF index: {idx}, pages: {len(img_list)}") + yield idx, single_pdf, img_list + + +if __name__ == '__main__': + pdf_dir = "assets/examples/example.pdf" + check_pdf(pdf_dir) \ No newline at end of file From f321e00997b9d2fba4591c38c76e38a799569a71 Mon Sep 17 00:00:00 2001 From: angelgarcia Date: Mon, 2 Sep 2024 16:56:58 +0000 Subject: [PATCH 3/8] Refactor logging and model initialization. Relocate logging configuration into utils/config.py and move model initialization functions to utils/model_tools.py. Additionally, separate detection and recognition functionalities into distinct modules to enhance code readability and modularity. --- .gitignore | 2 +- app.py | 255 +++---------------------- assets/examples/example.pdf | Bin 88355 -> 276751 bytes utils/{logging_config.py => config.py} | 32 ++-- utils/detection.py | 121 ++++++++++++ utils/model_tools.py | 60 ++++++ utils/pdf_tools.py | 8 +- utils/recognition.py | 109 +++++++++++ utils/visualize.py | 76 ++++++++ 9 files changed, 411 insertions(+), 252 deletions(-) rename utils/{logging_config.py => config.py} (79%) create mode 100644 utils/detection.py create mode 100644 utils/model_tools.py create mode 100644 utils/recognition.py create mode 100644 utils/visualize.py diff --git a/.gitignore b/.gitignore index 137e2d7..8481bab 100644 --- a/.gitignore +++ b/.gitignore @@ -5,7 +5,7 @@ output/* data/* temp* -test* +test-magic-pdf.py # python .ipynb_checkpoints diff --git a/app.py b/app.py index 2ea8ca9..17a9a77 100644 --- a/app.py +++ b/app.py @@ -1,101 +1,31 @@ # refactoring pdf_extract.py import os -import cv2 import json -import yaml import time -import pytz -import datetime -import argparse -import shutil -import torch -import numpy as np -import gc - -from paddleocr import draw_ocr -from PIL import Image, ImageDraw, ImageFont -from torchvision import transforms -from torch.utils.data import Dataset, DataLoader -from ultralytics import YOLO -from unimernet.common.config import Config -import unimernet.tasks as tasks -from unimernet.processors import load_processor -from struct_eqtable import build_model - -from modules.latex2png import tex2pil, zhtext2pil -from modules.extract_pdf import load_pdf_fitz -from modules.layoutlmv3.model_init import Layoutlmv3_Predictor from modules.self_modify import ModifiedPaddleOCR -from modules.post_process import get_croped_image, latex_rm_whitespace from utils.pdf_tools import check_pdf, process_all_pdfs -from utils.logging_config import setup_logging - -# Apply the logging configuration -logger = setup_logging('app') - - -def mfd_model_init(weight): - mfd_model = YOLO(weight) - return mfd_model - - -def mfr_model_init(weight_dir, device='cpu'): - args = argparse.Namespace(cfg_path="modules/UniMERNet/configs/demo.yaml", options=None) - cfg = Config(args) - cfg.config.model.pretrained = os.path.join(weight_dir, "pytorch_model.bin") - cfg.config.model.model_config.model_name = weight_dir - cfg.config.model.tokenizer_config.path = weight_dir - task = tasks.setup_task(cfg) - model = task.build_model(cfg) - model = model.to(device) - vis_processor = load_processor('formula_image_eval', cfg.config.datasets.formula_rec_eval.vis_processor.eval) - return model, vis_processor - +from utils.config import setup_logging -def layout_model_init(weight): - model = Layoutlmv3_Predictor(weight) - return model +from utils.model_tools import load_config +from utils.model_tools import mfd_model_init +from utils.model_tools import mfr_model_init +from utils.model_tools import layout_model_init +from utils.model_tools import tr_model_init +from utils.detection import layout_detection_and_formula +from utils.recognition import formula_recognition, ocr_table_recognition +from utils.visualize import get_visualize -def tr_model_init(weight, max_time, device='cuda'): - tr_model = build_model(weight, max_new_tokens=4096, max_time=max_time) - if device == 'cuda': - tr_model = tr_model.cuda() - return tr_model +from utils.detection import layout_detection, formula_detection - -class MathDataset(Dataset): - def __init__(self, image_paths, transform=None): - self.image_paths = image_paths - self.transform = transform - - def __len__(self): - return len(self.image_paths) - - def __getitem__(self, idx): - # if not pil image, then convert to pil image - if isinstance(self.image_paths[idx], str): - raw_image = Image.open(self.image_paths[idx]) - else: - raw_image = self.image_paths[idx] - if self.transform: - image = self.transform(raw_image) - return image +# Apply the logging configuration +logger = setup_logging('app') if __name__ == '__main__': - # parser = argparse.ArgumentParser() - # parser.add_argument('--pdf', type=str) - # parser.add_argument('--output', type=str, default="output") - # parser.add_argument('--batch-size', type=int, default=128) - # parser.add_argument('--vis', action='store_true') - # parser.add_argument('--render', action='store_true') - # parser.add_argument('--timezone', type=str, default="Asia/Shanghai") - # args = parser.parse_args() - # print(args) # Params pdf_path: str = '1706.03762.pdf' @@ -107,8 +37,7 @@ def __getitem__(self, idx): logger.info('Started!') start_0 = time.time() ## ======== model init ========## - with open('configs/model_configs.yaml') as f: - model_configs = yaml.load(f, Loader=yaml.FullLoader) + model_configs = load_config() img_size = model_configs['model_args']['img_size'] conf_thres = model_configs['model_args']['conf_thres'] @@ -116,12 +45,10 @@ def __getitem__(self, idx): device = model_configs['model_args']['device'] dpi = model_configs['model_args']['pdf_dpi'] - mfd_model = mfd_model_init(model_configs['model_args']['mfd_weight']) - mfr_model, mfr_vis_processors = mfr_model_init(model_configs['model_args']['mfr_weight'], device=device) - mfr_transform = transforms.Compose([mfr_vis_processors, ]) - tr_model = tr_model_init(model_configs['model_args']['tr_weight'], - max_time=model_configs['model_args']['table_max_time'], device=device) - layout_model = layout_model_init(model_configs['model_args']['layout_weight']) + mfd_model = mfd_model_init() + mfr_model, mfr_transform = mfr_model_init() + tr_model = tr_model_init() + layout_model = layout_model_init() ocr_model = ModifiedPaddleOCR(show_log=True) logger.info(f'Model init done in {int(time.time() - start_0)}s!') @@ -133,96 +60,16 @@ def __getitem__(self, idx): # layout detection and formula detection logger.debug('layout detection and formula detection') - doc_layout_result = [] - latex_filling_list = [] - mf_image_list = [] - for idx, image in enumerate(img_list): - img_H, img_W = image.shape[0], image.shape[1] - layout_res = layout_model(image, ignore_catids=[]) - mfd_res = mfd_model.predict(image, imgsz=img_size, conf=conf_thres, iou=iou_thres, verbose=True)[0] - for xyxy, conf, cla in zip(mfd_res.boxes.xyxy.cpu(), mfd_res.boxes.conf.cpu(), mfd_res.boxes.cls.cpu()): - xmin, ymin, xmax, ymax = [int(p.item()) for p in xyxy] - new_item = { - 'category_id': 13 + int(cla.item()), - 'poly': [xmin, ymin, xmax, ymin, xmax, ymax, xmin, ymax], - 'score': round(float(conf.item()), 2), - 'latex': '', - } - layout_res['layout_dets'].append(new_item) - latex_filling_list.append(new_item) - bbox_img = get_croped_image(Image.fromarray(image), [xmin, ymin, xmax, ymax]) - mf_image_list.append(bbox_img) - - layout_res['page_info'] = dict( - page_no=idx, - height=img_H, - width=img_W - ) - doc_layout_result.append(layout_res) - - del mfd_res - torch.cuda.empty_cache() - gc.collect() + # doc_layout_result, latex_filling_list, mf_image_list = layout_detection_and_formula(img_list, layout_model, mfd_model) + doc_layout_result = layout_detection(img_list, layout_model) + doc_layout_result, latex_filling_list, mf_image_list = formula_detection(img_list, doc_layout_result, mfd_model) # Formula recognition, collect all formula images in whole pdf file, then batch infer them. - a = time.time() - logger.debug('Formula recognition') - dataset = MathDataset(mf_image_list, transform=mfr_transform) - dataloader = DataLoader(dataset, batch_size=batch_size, num_workers=32) - mfr_res = [] - for imgs in dataloader: - imgs = imgs.to(device) - output = mfr_model.generate({'image': imgs}) - mfr_res.extend(output['pred_str']) - for res, latex in zip(latex_filling_list, mfr_res): - res['latex'] = latex_rm_whitespace(latex) - b = time.time() - print("formula nums:", len(mf_image_list), "mfr time:", round(b - a, 2)) + formula_recognition(mf_image_list, latex_filling_list, mfr_model, mfr_transform, batch_size) # ocr and table recognition - logger.debug('ocr and table recognition') - for idx, image in enumerate(img_list): - pil_img = Image.fromarray(cv2.cvtColor(image, cv2.COLOR_RGB2BGR)) - single_page_res = doc_layout_result[idx]['layout_dets'] - single_page_mfdetrec_res = [] - for res in single_page_res: - if int(res['category_id']) in [13, 14]: - xmin, ymin = int(res['poly'][0]), int(res['poly'][1]) - xmax, ymax = int(res['poly'][4]), int(res['poly'][5]) - single_page_mfdetrec_res.append({ - "bbox": [xmin, ymin, xmax, ymax], - }) - for res in single_page_res: - if int(res['category_id']) in [0, 1, 2, 4, 6, 7]: # categories that need to do ocr - xmin, ymin = int(res['poly'][0]), int(res['poly'][1]) - xmax, ymax = int(res['poly'][4]), int(res['poly'][5]) - crop_box = [xmin, ymin, xmax, ymax] - cropped_img = Image.new('RGB', pil_img.size, 'white') - cropped_img.paste(pil_img.crop(crop_box), crop_box) - cropped_img = cv2.cvtColor(np.asarray(cropped_img), cv2.COLOR_RGB2BGR) - ocr_res = ocr_model.ocr(cropped_img, mfd_res=single_page_mfdetrec_res)[0] - if ocr_res: - for box_ocr_res in ocr_res: - p1, p2, p3, p4 = box_ocr_res[0] - text, score = box_ocr_res[1] - doc_layout_result[idx]['layout_dets'].append({ - 'category_id': 15, - 'poly': p1 + p2 + p3 + p4, - 'score': round(score, 2), - 'text': text, - }) - elif int(res['category_id']) == 5: # do table recognition - xmin, ymin = int(res['poly'][0]), int(res['poly'][1]) - xmax, ymax = int(res['poly'][4]), int(res['poly'][5]) - crop_box = [xmin, ymin, xmax, ymax] - cropped_img = pil_img.convert("RGB").crop(crop_box) - start = time.time() - with torch.no_grad(): - output = tr_model(cropped_img) - end = time.time() - if (end - start) > model_configs['model_args']['table_max_time']: - res["timeout"] = True - res["latex"] = output[0] + doc_layout_result = ocr_table_recognition(img_list, doc_layout_result, ocr_model, tr_model) + os.makedirs(output_dir, exist_ok=True) basename = os.path.basename(single_pdf)[0:-4] @@ -231,60 +78,6 @@ def __getitem__(self, idx): json.dump(doc_layout_result, f) if vis: - color_palette = [ - (255, 64, 255), (255, 255, 0), (0, 255, 255), (255, 215, 135), (215, 0, 95), (100, 0, 48), (0, 175, 0), - (95, 0, 95), (175, 95, 0), (95, 95, 0), - (95, 95, 255), (95, 175, 135), (215, 95, 0), (0, 0, 255), (0, 255, 0), (255, 0, 0), (0, 95, 215), - (0, 0, 0), (0, 0, 0), (0, 0, 0) - ] - id2names = ["title", "plain_text", "abandon", "figure", "figure_caption", "table", "table_caption", - "table_footnote", - "isolate_formula", "formula_caption", " ", " ", " ", "inline_formula", "isolated_formula", - "ocr_text"] - vis_pdf_result = [] - for idx, image in enumerate(img_list): - single_page_res = doc_layout_result[idx]['layout_dets'] - vis_img = Image.new('RGB', Image.fromarray(image).size, 'white') if render else Image.fromarray( - cv2.cvtColor(image, cv2.COLOR_RGB2BGR)) - draw = ImageDraw.Draw(vis_img) - for res in single_page_res: - label = int(res['category_id']) - if label > 15: # categories that do not need visualize - continue - label_name = id2names[label] - x_min, y_min = int(res['poly'][0]), int(res['poly'][1]) - x_max, y_max = int(res['poly'][4]), int(res['poly'][5]) - if render and label in [13, 14, 15]: - try: - if label in [13, 14]: # render formula - window_img = tex2pil(res['latex'])[0] - else: - if True: # render chinese - window_img = zhtext2pil(res['text']) - else: # render english - window_img = tex2pil([res['text']], tex_type="text")[0] - ratio = min((x_max - x_min) / window_img.width, (y_max - y_min) / window_img.height) - 0.05 - window_img = window_img.resize( - (int(window_img.width * ratio), int(window_img.height * ratio))) - vis_img.paste(window_img, (int(x_min + (x_max - x_min - window_img.width) / 2), - int(y_min + (y_max - y_min - window_img.height) / 2))) - except Exception as e: - print(f"got exception on {text}, error info: {e}") - draw.rectangle([x_min, y_min, x_max, y_max], fill=None, outline=color_palette[label], width=1) - fontText = ImageFont.truetype("assets/fonts/simhei.ttf", 15, encoding="utf-8") - draw.text((x_min, y_min), label_name, color_palette[label], font=fontText) - - width, height = vis_img.size - width, height = int(0.75 * width), int(0.75 * height) - vis_img = vis_img.resize((width, height)) - vis_pdf_result.append(vis_img) - - first_page = vis_pdf_result.pop(0) - first_page.save(os.path.join(output_dir, f'{basename}.pdf'), 'PDF', resolution=100, save_all=True, - append_images=vis_pdf_result) - try: - shutil.rmtree('./temp') - except: - pass + get_visualize(img_list, doc_layout_result, render, output_dir, basename) logger.info(f'Finished! time cost: {int(time.time() - start_0)} s') \ No newline at end of file diff --git a/assets/examples/example.pdf b/assets/examples/example.pdf index 19aa73bab1fee4507c7cdd6feb26aa43ba7fcc92..2f158e5735b76b2d9f0ebd5fa10faeb870449ca4 100644 GIT binary patch literal 276751 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 literal 88355 zcmeFZc|28L)IUx|hRn0jB{I8%d5Fwori__~Ysx%?B0`xHC9|XwNyr!>GZ`8Xg$yB6 zhL8~b&bilh@2Ai7Jg?{X`hK79_xt>D?mlOowbovH?Y-CDXTSHpT)Ijsf^Z>m60W9- zrb-eLI0OdquyrAkk%1ZndLp5UHoi8l9*$658%LxM1OdF&mz5fRh(UY zk={@hR~uiX64K7Y9tlYJ_fky2UG3JTo<)B7;baCZ|UuuY$;TZIuu4kPIq*0VKm~Dms)0%_jr|Xuhy$PT7!GZ7adq$ zcZ=)D3Ep7KilVck8$QVfPkUuZBxg(0n`H0=krT#Um@7$QtV|Ii{d)HP+IV;V_W)L9 zJs6|gaQyrxPx?ovL9^}ZVy!y)_wI?0`3(z=QXB$9&XjT}i-!z8bTKZj?=g<|z+AL>V2iGlAS%Mqp*`*Q*Mocy>lN& zO7>brFV1T88K-wIyej{w`%e3^^hJuq5VyG%^`i8&ko)FHGiB$%A|cme2-D86-|Tw) zmI^#=Ik~dqizzj?zlLqPCOy~fmd)FDbd;fds(=DOeiLZU7tW*d6GZ~42h zjA&e)v)01(U!**zARihIYY@arZt8~JZEt1Jy_rvmC^<6t=xLBgdBM|ThT0KR`c&tI zklU%R3M=MDf_zAOqAjE?`;)WzRQT*a515=_G_|EC);t$MIbs%S*38X&QchBmBZ$OE zi6gAdUCW|E`gUIQTKD+N&2c=1_@gtg-k)q*%{?KlL)Urq@K@#0YBgS1G2FAw%MPZ9NI*EM_i_%VK&d_Nx{(6e>S(Ndq{t=!TX3bGVI z&&V$NmT)8~wa~DcnQ6(f_fi^O%sRt@@i}+PT<5>AhUG3kgoSpB;B~Yzy^P~@iqKN( zQ#<;a-e!a)Z789rgF!aOAm`Z)P8ue;{4bO8H@l9?Z>!ApYuot$IBeWhIh@SH!znvP z<5RMS)J=>gWN5j*F1WQN#aGi|(+W{l{QUf7H2RnozfSN&`dtQL zrrn3T*74mN*)<9GqI1_hPamCQBzxvEcwDi4jL(?EvC$%7alGxU=$Pk2?~eFpkob?>d9MZk++yHhQd;|q{w(_E{wyx`Fa0^*P%~jz zjIw^mIDb}Ii0{q=P7Ng}*+o6;!>-ijF3=3NYHGK_n_R}XCx5&f`@}?Ecc;oLBi7;i zl+1ch;icN`i7y<~dKIS5d9jGeMEh&+sMMczrwa7(48}|cQW@?TzOXP33A@7~*y@`W zPM;8UD_!^+36tivbrSyvImM$Z;~dkO>Xrf)GOa~s+@|MS+{T8&uj$CWZ7gB05p~na z6z-ErFJKLG6+dpKBR{s@vz;*il`rv7(_1|b#ui@+HuG9J->)ePO}VAY;!pm^sqn zX*j<0jpo^){sDi=Sue*4n?!cTjda=-Rlt*TZC?rG|&c1Fy^S`-ZYTH!m?)hoq~U zQm<3s3Cy-rEI+pTH1_MF@#z;K3@@t_&lo;4pV7EePC5*ajm z>`X8LpX}>BhNSjd3gZy&v{0`Vq`5C|ud~PEkA%`≶wswf-x;+tiz4gcpQBq+x)Di*S$^LK=%Qy@ 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 max_time: + res["timeout"] = True + res["latex"] = output[0] + + logger.info(f'ocr and table recognition done in: {round(time.time() - start, 2)}') + + return doc_layout_result \ No newline at end of file diff --git a/utils/visualize.py b/utils/visualize.py new file mode 100644 index 0000000..5b44128 --- /dev/null +++ b/utils/visualize.py @@ -0,0 +1,76 @@ +import os +import shutil +import cv2 +from PIL import Image, ImageDraw, ImageFont + +from modules.latex2png import tex2pil, zhtext2pil +from utils.config import setup_logging + +# Apply the logging configuration +logger = setup_logging('visualize') + +color_palette = [ + (255, 64, 255), (255, 255, 0), (0, 255, 255), (255, 215, 135), (215, 0, 95), (100, 0, 48), (0, 175, 0), + (95, 0, 95), (175, 95, 0), (95, 95, 0), + (95, 95, 255), (95, 175, 135), (215, 95, 0), (0, 0, 255), (0, 255, 0), (255, 0, 0), (0, 95, 215), + (0, 0, 0), (0, 0, 0), (0, 0, 0) +] +id2names = ["title", "plain_text", "abandon", "figure", "figure_caption", "table", "table_caption", + "table_footnote", + "isolate_formula", "formula_caption", " ", " ", " ", "inline_formula", "isolated_formula", + "ocr_text"] + +def get_visualize(img_list: list, doc_layout_result, render: bool, output_dir, basename): + + vis_pdf_result = [] + for idx, image in enumerate(img_list): + single_page_res = doc_layout_result[idx]['layout_dets'] + vis_img = Image.new('RGB', Image.fromarray(image).size, 'white') if render else Image.fromarray( + cv2.cvtColor(image, cv2.COLOR_RGB2BGR)) + draw = ImageDraw.Draw(vis_img) + for res in single_page_res: + label = int(res['category_id']) + if label > 15: # categories that do not need visualize + continue + label_name = id2names[label] + x_min, y_min = int(res['poly'][0]), int(res['poly'][1]) + x_max, y_max = int(res['poly'][4]), int(res['poly'][5]) + if render and label in [13, 14, 15]: + try: + if label in [13, 14]: # render formula + window_img = tex2pil(res['latex'])[0] + else: + if True: # render chinese + window_img = zhtext2pil(res['text']) + else: # render english + window_img = tex2pil([res['text']], tex_type="text")[0] + ratio = min((x_max - x_min) / window_img.width, (y_max - y_min) / window_img.height) - 0.05 + window_img = window_img.resize( + (int(window_img.width * ratio), int(window_img.height * ratio))) + vis_img.paste(window_img, (int(x_min + (x_max - x_min - window_img.width) / 2), + int(y_min + (y_max - y_min - window_img.height) / 2))) + except Exception as e: + logger.error(f"got exception on {text}, error info: {e}") + + draw.rectangle([x_min, y_min, x_max, y_max], fill=None, outline=color_palette[label], width=1) + fontText = ImageFont.truetype("assets/fonts/simhei.ttf", 15, encoding="utf-8") + draw.text((x_min, y_min), label_name, color_palette[label], font=fontText) + + width, height = vis_img.size + width, height = int(0.75 * width), int(0.75 * height) + vis_img = vis_img.resize((width, height)) + vis_pdf_result.append(vis_img) + + first_page = vis_pdf_result.pop(0) + first_page.save( + fp=os.path.join(output_dir, f'{basename}.pdf'), + format='PDF', + resolution=100, + save_all=True, + append_images=vis_pdf_result + ) + try: + shutil.rmtree('./temp') + except Exception as e: + logger.error(f"got exception on shutil.rmtree, error info: {e}") + pass \ No newline at end of file From cbaf25dffb525a8da02b0d0f7397c50f75550a6b Mon Sep 17 00:00:00 2001 From: angelgarcia Date: Tue, 3 Sep 2024 16:17:39 +0000 Subject: [PATCH 4/8] Refactor OCR and table recognition logic Separated OCR recognition and table recognition into distinct functions. This improves code readability and maintainability by isolating each recognition task, enabling easier debugging and future enhancements. --- app.py | 9 +++--- utils/recognition.py | 75 +++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 79 insertions(+), 5 deletions(-) diff --git a/app.py b/app.py index 17a9a77..1cf986a 100644 --- a/app.py +++ b/app.py @@ -15,8 +15,8 @@ from utils.model_tools import layout_model_init from utils.model_tools import tr_model_init -from utils.detection import layout_detection_and_formula from utils.recognition import formula_recognition, ocr_table_recognition +from utils.recognition import ocr_recognition, table_recognition from utils.visualize import get_visualize from utils.detection import layout_detection, formula_detection @@ -60,7 +60,6 @@ # layout detection and formula detection logger.debug('layout detection and formula detection') - # doc_layout_result, latex_filling_list, mf_image_list = layout_detection_and_formula(img_list, layout_model, mfd_model) doc_layout_result = layout_detection(img_list, layout_model) doc_layout_result, latex_filling_list, mf_image_list = formula_detection(img_list, doc_layout_result, mfd_model) @@ -68,7 +67,8 @@ formula_recognition(mf_image_list, latex_filling_list, mfr_model, mfr_transform, batch_size) # ocr and table recognition - doc_layout_result = ocr_table_recognition(img_list, doc_layout_result, ocr_model, tr_model) + doc_layout_result = ocr_recognition(img_list, doc_layout_result, ocr_model) + doc_layout_result = table_recognition(img_list, doc_layout_result, tr_model) os.makedirs(output_dir, exist_ok=True) @@ -80,4 +80,5 @@ if vis: get_visualize(img_list, doc_layout_result, render, output_dir, basename) - logger.info(f'Finished! time cost: {int(time.time() - start_0)} s') \ No newline at end of file + logger.info(f'Finished! time cost: {int(time.time() - start_0)} s') + logger.info('----------------------------------------') \ No newline at end of file diff --git a/utils/recognition.py b/utils/recognition.py index 294b631..4969031 100644 --- a/utils/recognition.py +++ b/utils/recognition.py @@ -65,7 +65,7 @@ def ocr_table_recognition(img_list: list, doc_layout_result, ocr_model, tr_model single_page_res = doc_layout_result[idx]['layout_dets'] single_page_mfdetrec_res = [] for res in single_page_res: - if int(res['category_id']) in [13, 14]: + if int(res['category_id']) in [13, 14]: # categories formula xmin, ymin = int(res['poly'][0]), int(res['poly'][1]) xmax, ymax = int(res['poly'][4]), int(res['poly'][5]) single_page_mfdetrec_res.append({ @@ -106,4 +106,77 @@ def ocr_table_recognition(img_list: list, doc_layout_result, ocr_model, tr_model logger.info(f'ocr and table recognition done in: {round(time.time() - start, 2)}') + return doc_layout_result + + +def ocr_recognition(img_list: list, doc_layout_result, ocr_model): + logger.debug('ocr recognition') + start = time.time() + + for idx, image in enumerate(img_list): + pil_img = Image.fromarray(cv2.cvtColor(image, cv2.COLOR_RGB2BGR)) + single_page_res = doc_layout_result[idx]['layout_dets'] + single_page_mfdetrec_res = [] + for res in single_page_res: + if int(res['category_id']) in [13, 14]: # categories formula + xmin, ymin = int(res['poly'][0]), int(res['poly'][1]) + xmax, ymax = int(res['poly'][4]), int(res['poly'][5]) + single_page_mfdetrec_res.append({ + "bbox": [xmin, ymin, xmax, ymax], + }) + for res in single_page_res: + if int(res['category_id']) in [0, 1, 2, 4, 6, 7]: # categories that need to do ocr + xmin, ymin = int(res['poly'][0]), int(res['poly'][1]) + xmax, ymax = int(res['poly'][4]), int(res['poly'][5]) + crop_box = [xmin, ymin, xmax, ymax] + cropped_img = Image.new('RGB', pil_img.size, 'white') + cropped_img.paste(pil_img.crop(crop_box), crop_box) + cropped_img = cv2.cvtColor(np.asarray(cropped_img), cv2.COLOR_RGB2BGR) + ocr_res = ocr_model.ocr(cropped_img, mfd_res=single_page_mfdetrec_res)[0] + if ocr_res: + for box_ocr_res in ocr_res: + p1, p2, p3, p4 = box_ocr_res[0] + text, score = box_ocr_res[1] + doc_layout_result[idx]['layout_dets'].append({ + 'category_id': 15, + 'poly': p1 + p2 + p3 + p4, + 'score': round(score, 2), + 'text': text, + }) + + logger.info(f'ocr recognition done in: {round(time.time() - start, 2)}') + + return doc_layout_result + + +def table_recognition(img_list, doc_layout_result, tr_model): + model_configs = load_config() + max_time = model_configs['model_args']['table_max_time'] + + logger.debug('table recognition') + start_0 = time.time() + + for idx, image in enumerate(img_list): + pil_img = Image.fromarray(image) + single_page_res = doc_layout_result[idx]['layout_dets'] + + for jdx, res in enumerate(single_page_res): + if int(res['category_id']) == 5: # do table recognition + xmin, ymin = int(res['poly'][0]), int(res['poly'][1]) + xmax, ymax = int(res['poly'][4]), int(res['poly'][5]) + crop_box = [xmin, ymin, xmax, ymax] + cropped_img = pil_img.crop(crop_box) + + start = time.time() + with torch.no_grad(): + start_1 = time.time() + output = tr_model(cropped_img) # It takes a lot of time + logger.debug(f'{idx} - {jdx} tr_model generate in: {time.time() - start_1}s') + + if (time.time() - start) > max_time: + res["timeout"] = True + res["latex"] = output[0] + + logger.info(f'table recognition done in: {round(time.time() - start_0, 2)}') + return doc_layout_result \ No newline at end of file From bea8f114e1f4f56ee379e991a0cf7acbe4d1431a Mon Sep 17 00:00:00 2001 From: angelgarcia Date: Tue, 3 Sep 2024 16:49:39 +0000 Subject: [PATCH 5/8] Refactor PDF tools to use a class-based structure Replaced standalone functions in `pdf_tools.py` with a new `PDFProcessor` class to encapsulate PDF processing logic. Adjusted `app.py` to use the new `PDFProcessor` class methods, improving code organization and maintainability. --- app.py | 22 ++--- utils/pdf_tools.py | 194 +++++++++++++++++++++++++++++---------------- 2 files changed, 131 insertions(+), 85 deletions(-) diff --git a/app.py b/app.py index 1cf986a..07094c6 100644 --- a/app.py +++ b/app.py @@ -6,16 +6,15 @@ from modules.self_modify import ModifiedPaddleOCR -from utils.pdf_tools import check_pdf, process_all_pdfs +from utils.pdf_tools import PDFProcessor from utils.config import setup_logging -from utils.model_tools import load_config from utils.model_tools import mfd_model_init from utils.model_tools import mfr_model_init from utils.model_tools import layout_model_init from utils.model_tools import tr_model_init -from utils.recognition import formula_recognition, ocr_table_recognition +from utils.recognition import formula_recognition from utils.recognition import ocr_recognition, table_recognition from utils.visualize import get_visualize @@ -37,29 +36,22 @@ logger.info('Started!') start_0 = time.time() ## ======== model init ========## - model_configs = load_config() - - img_size = model_configs['model_args']['img_size'] - conf_thres = model_configs['model_args']['conf_thres'] - iou_thres = model_configs['model_args']['iou_thres'] - device = model_configs['model_args']['device'] - dpi = model_configs['model_args']['pdf_dpi'] - mfd_model = mfd_model_init() mfr_model, mfr_transform = mfr_model_init() tr_model = tr_model_init() layout_model = layout_model_init() ocr_model = ModifiedPaddleOCR(show_log=True) - logger.info(f'Model init done in {int(time.time() - start_0)}s!') ## ======== model init ========## start_0 = time.time() - all_pdfs = check_pdf(pdf_path) - for idx, single_pdf, img_list in process_all_pdfs(all_pdfs, dpi): + + pdf_processor = PDFProcessor() + all_pdfs = pdf_processor.check_pdf(pdf_path) + + for idx, single_pdf, img_list in pdf_processor.process_all_pdfs(all_pdfs): # layout detection and formula detection - logger.debug('layout detection and formula detection') doc_layout_result = layout_detection(img_list, layout_model) doc_layout_result, latex_filling_list, mf_image_list = formula_detection(img_list, doc_layout_result, mfd_model) diff --git a/utils/pdf_tools.py b/utils/pdf_tools.py index 4741a74..bab2e97 100644 --- a/utils/pdf_tools.py +++ b/utils/pdf_tools.py @@ -1,85 +1,139 @@ import os +from typing import List, Optional, Generator from modules.extract_pdf import load_pdf_fitz -from utils.config import setup_logging +from utils.config import load_config, setup_logging -# Apply the logging configuration -logger = setup_logging('pdf_tools') - -def check_pdf(pdf_path: str): - """ - Checks if the given path is a directory or a single PDF file. - If it is a directory, it retrieves all the PDF files within the directory. - Otherwise, it treats the path as a single PDF file. - - :param pdf_path: The path to the directory or PDF file. - :type pdf_path: str - :returns: A list of PDF file paths. - :rtype: list[str] +class PDFProcessor: """ - if os.path.isdir(pdf_path): - all_pdfs = [os.path.join(pdf_path, name) for name in os.listdir(pdf_path)] - else: - all_pdfs = [pdf_path] - logger.info(f"Total files: {len(all_pdfs)}") - return all_pdfs + Class PDFTools + This class provides a set of tools for working with PDF files. -def get_images(single_pdf: str, dpi: int = 200) -> list | None: - """ - This function retrieves a list of images from a given PDF file. - It uses the `load_pdf_fitz()` function to load the PDF and convert its contents into images. + Methods: + - __init__(config_path: Optional[str] = None): Initializes the PDFTools object. + - load_config(config_path: Optional[str] = None) -> dict: Loads the configuration from a JSON file. + - setup_logging(name: str) -> Logger: Sets up the logging configuration. - Parameters: - - `single_pdf` (str): The path to the PDF file. - - `dpi` (int): The resolution at which the PDF should be converted to images. Default is 200. + Attributes: + - config: A dictionary containing the configuration settings. + - dpi: The DPI (dots per inch) for the PDF files. + - logger: The logger object for logging messages. - Returns: - - list or None: A list of images if the conversion was successful, otherwise None. - Raises: - - Any exceptions raised by the `load_pdf_fitz()` function are caught and logged, and the function returns None. - """ - try: - img_list = load_pdf_fitz(single_pdf, dpi=dpi) - except Exception as e: - logger.error(f"Unexpected error with PDF file '{single_pdf}': {e}") - return None - return img_list + __init__(config_path: Optional[str] = None) + Initializes the PDFTools object. + Parameters: + config_path (Optional[str]): Path to the configuration file. If None, the default configuration file will be loaded. -def process_all_pdfs(all_pdfs: list, dpi: int = 200): - """ - Processes a list of PDF files and yields information about each PDF file. - - Args: - all_pdfs (list): A list of paths to PDF files. - dpi (int, optional): DPI (dots per inch) value for converting PDF to images. Default is 200. - - Yields: - Tuple[int, str, List]: A tuple containing the following information: - - PDF index (int): Index of the PDF file in the list. - - PDF path (str): Path to the PDF file. - - PDF images (List): A list of images extracted from the PDF file. - - Notes: - - If an error occurs while processing a PDF file, it will be skipped and the next PDF file will be processed. - - The logger will output information about the PDF index and the number of pages in each PDF file. - - Example: - >>> pdfs = [ - ... 'path/to/file1.pdf', - ... 'path/to/file2.pdf', - ... ] - >>> for idx, pdf, images in process_all_pdfs(pdfs): - ... print(f"PDF index: {idx}, PDF path: {pdf}, Number of images: {len(images)}") - """ - for idx, single_pdf in enumerate(all_pdfs): - img_list = get_images(single_pdf, dpi) + load_config(config_path: Optional[str] = None) -> dict + Loads the configuration from a JSON file. - if img_list is None: - continue + Parameters: + config_path (Optional[str]): Path to the configuration file. If None, the default configuration file will be loaded. + + Returns: + dict: A dictionary containing the configuration settings. + + setup_logging(name: str) -> Logger + Sets up the logging configuration. + + Parameters: + name (str): The name of the logger. + + Returns: + Logger: The logger object for logging messages. + + Attributes: + - config (dict): A dictionary containing the configuration settings. + - dpi (int): The DPI (dots per inch) for the PDF files. + - logger (Logger): The logger object for logging messages. + """ - logger.info(f"PDF index: {idx}, pages: {len(img_list)}") - yield idx, single_pdf, img_list + def __init__(self, config_path: Optional[str] = None): + + self.config = load_config(config_path) if config_path else load_config() + self.dpi = self.config['model_args']['pdf_dpi'] + self.logger = setup_logging('pdf_tools') + + def check_pdf(self, pdf_path: str) -> List[str]: + """ + This method is used to check if a given file path is a directory or a single PDF file. + + Parameters: + - pdf_path (str): The file path to check. It can be either a directory or a single PDF file path. + + Returns: + - List[str]: A list of PDF file paths. + + Example Usage: + ``` + pdf_checker = PDFChecker() + result = pdf_checker.check_pdf('/path/to/pdfs') + print(result) + ``` + + Note: This method will return an empty list if no PDF files are found in the given directory or if the given file path is not a PDF file. + """ + if os.path.isdir(pdf_path): + all_pdfs = [os.path.join(pdf_path, name) for name in os.listdir(pdf_path) if name.endswith('.pdf')] + else: + all_pdfs = [pdf_path] + self.logger.info(f"Total files: {len(all_pdfs)}") + return all_pdfs + + def get_images(self, single_pdf: str) -> Optional[List[str]]: + """ + This method retrieves a list of images from a single PDF file. + + Parameters: + - single_pdf: A string representing the path to the PDF file. + + Returns: + - Optional[List[str]]: A list of strings representing the images extracted from the PDF file. Returns None if there was an error during the extraction process. + + Raises: + - None + + Example: + obj = MyClass() + images = obj.get_images('example.pdf') + """ + try: + img_list = load_pdf_fitz(single_pdf, self.dpi) + except Exception as e: + self.logger.error(f"Unexpected error with PDF file '{single_pdf}': {e}") + return None + return img_list + + def process_all_pdfs(self, all_pdfs: List[str]) -> Generator[tuple[int, str, List[str]], None, None]: + """ + This method `process_all_pdfs` processes a list of PDF files and returns a generator that yields a tuple for each PDF file. The tuple contains the index of the PDF file in the list, the path of the PDF file, and a list of image paths extracted from the PDF file. + + Parameters: + - `self`: The current instance of the class. + - `all_pdfs`: A List of strings representing the paths of the PDF files to be processed. + + Returns: + - `Generator[tuple[int, str, List[str]], None, None]`: A generator that yields a tuple for each PDF file. The tuple contains the index of the PDF file, the path of the PDF file, and a list of image paths extracted from the PDF file. + + Example usage: + ```python + pdf_processor = PDFProcessor() + pdf_files = ["file1.pdf", "file2.pdf", "file3.pdf"] + for index, path, images in pdf_processor.process_all_pdfs(pdf_files): + print(f"Processing PDF index: {index}") + print(f"PDF path: {path}") + print(f"Images: {images}") + ``` + """ + for idx, single_pdf in enumerate(all_pdfs): + img_list = self.get_images(single_pdf) + + if img_list is None: + continue + + self.logger.info(f"PDF index: {idx}, pages: {len(img_list)}") + yield idx, single_pdf, img_list \ No newline at end of file From c084eec2ec8acde9ef9e9a545165e1aca0bf4468 Mon Sep 17 00:00:00 2001 From: angelgarcia Date: Tue, 3 Sep 2024 18:28:14 +0000 Subject: [PATCH 6/8] Refactor and modularize code for improved clarity Deleted redundant utility files and integrated functionality into new, focused modules under `app_tools`. Introduced `TableProcessor`, `LayoutAnalyzer`, `FormulaProcessor`, and `OCRProcessor` classes to handle specific operations. Updated `app.py` to reflect these changes and streamline the process flow. --- app.py | 65 ++---- {utils => app_tools}/__init__.py | 0 {utils => app_tools}/config.py | 0 app_tools/formula_analysis.py | 262 +++++++++++++++++++++++++ app_tools/layout_analysis.py | 146 ++++++++++++++ app_tools/ocr_analysis.py | 100 ++++++++++ utils/pdf_tools.py => app_tools/pdf.py | 4 +- app_tools/table_analysis.py | 144 ++++++++++++++ app_tools/utils.py | 18 ++ {utils => app_tools}/visualize.py | 22 ++- utils/detection.py | 121 ------------ utils/model_tools.py | 60 ------ utils/recognition.py | 182 ----------------- 13 files changed, 714 insertions(+), 410 deletions(-) rename {utils => app_tools}/__init__.py (100%) rename {utils => app_tools}/config.py (100%) create mode 100644 app_tools/formula_analysis.py create mode 100644 app_tools/layout_analysis.py create mode 100644 app_tools/ocr_analysis.py rename utils/pdf_tools.py => app_tools/pdf.py (97%) create mode 100644 app_tools/table_analysis.py create mode 100644 app_tools/utils.py rename {utils => app_tools}/visualize.py (72%) delete mode 100644 utils/detection.py delete mode 100644 utils/model_tools.py delete mode 100644 utils/recognition.py diff --git a/app.py b/app.py index 07094c6..01749a6 100644 --- a/app.py +++ b/app.py @@ -1,26 +1,15 @@ # refactoring pdf_extract.py - -import os -import json import time -from modules.self_modify import ModifiedPaddleOCR - -from utils.pdf_tools import PDFProcessor -from utils.config import setup_logging - -from utils.model_tools import mfd_model_init -from utils.model_tools import mfr_model_init -from utils.model_tools import layout_model_init -from utils.model_tools import tr_model_init - -from utils.recognition import formula_recognition -from utils.recognition import ocr_recognition, table_recognition -from utils.visualize import get_visualize - -from utils.detection import layout_detection, formula_detection +from app_tools.config import setup_logging +from app_tools.pdf import PDFProcessor +from app_tools.layout_analysis import LayoutAnalyzer +from app_tools.formula_analysis import FormulaProcessor +from app_tools.ocr_analysis import OCRProcessor +from app_tools.table_analysis import TableProcessor +from app_tools.visualize import get_visualize +from app_tools.utils import save_file -# Apply the logging configuration logger = setup_logging('app') @@ -34,43 +23,31 @@ render: bool = False logger.info('Started!') - start_0 = time.time() + start = time.time() ## ======== model init ========## - mfd_model = mfd_model_init() - mfr_model, mfr_transform = mfr_model_init() - tr_model = tr_model_init() - layout_model = layout_model_init() - ocr_model = ModifiedPaddleOCR(show_log=True) - logger.info(f'Model init done in {int(time.time() - start_0)}s!') + analyzer = LayoutAnalyzer() + formulas = FormulaProcessor() + ocr_processor = OCRProcessor(show_log=True) + table_processor = TableProcessor() + logger.info(f'Model init done in {int(time.time() - start)}s!') ## ======== model init ========## - start_0 = time.time() - + start = time.time() pdf_processor = PDFProcessor() all_pdfs = pdf_processor.check_pdf(pdf_path) for idx, single_pdf, img_list in pdf_processor.process_all_pdfs(all_pdfs): - # layout detection and formula detection - doc_layout_result = layout_detection(img_list, layout_model) - doc_layout_result, latex_filling_list, mf_image_list = formula_detection(img_list, doc_layout_result, mfd_model) - - # Formula recognition, collect all formula images in whole pdf file, then batch infer them. - formula_recognition(mf_image_list, latex_filling_list, mfr_model, mfr_transform, batch_size) - - # ocr and table recognition - doc_layout_result = ocr_recognition(img_list, doc_layout_result, ocr_model) - doc_layout_result = table_recognition(img_list, doc_layout_result, tr_model) - + doc_layout_result = analyzer.detect_layout(img_list) + doc_layout_result = formulas.detect_recognize_formulas(img_list, doc_layout_result) + doc_layout_result = ocr_processor.recognize_ocr(img_list, doc_layout_result) + doc_layout_result = table_processor.recognize_tables(img_list, doc_layout_result) - os.makedirs(output_dir, exist_ok=True) - basename = os.path.basename(single_pdf)[0:-4] + basename = save_file(output_dir, single_pdf, doc_layout_result) logger.debug(f'Save file: {basename}.json') - with open(os.path.join(output_dir, f'{basename}.json'), 'w') as f: - json.dump(doc_layout_result, f) if vis: get_visualize(img_list, doc_layout_result, render, output_dir, basename) - logger.info(f'Finished! time cost: {int(time.time() - start_0)} s') + logger.info(f'Finished! time cost: {int(time.time() - start)} s') logger.info('----------------------------------------') \ No newline at end of file diff --git a/utils/__init__.py b/app_tools/__init__.py similarity index 100% rename from utils/__init__.py rename to app_tools/__init__.py diff --git a/utils/config.py b/app_tools/config.py similarity index 100% rename from utils/config.py rename to app_tools/config.py diff --git a/app_tools/formula_analysis.py b/app_tools/formula_analysis.py new file mode 100644 index 0000000..9266cee --- /dev/null +++ b/app_tools/formula_analysis.py @@ -0,0 +1,262 @@ +import os, gc +import time +import argparse +from typing import List, Tuple + +from PIL import Image +import torch +from torch.utils.data import Dataset, DataLoader +from torchvision import transforms +from ultralytics import YOLO +from unimernet.common.config import Config +import unimernet.tasks as tasks +from unimernet.processors import load_processor +from modules.post_process import get_croped_image, latex_rm_whitespace + +from app_tools.config import load_config, setup_logging + +logger = setup_logging('formula_analysis') + +class MathDataset(Dataset): + """ + MathDataset class + + A class representing a dataset for mathematical operations. + + Attributes: + image_paths (list): A list of image paths. + transform (callable): A function or transformation to apply to the images. + + Methods: + __init__(self, image_paths, transform=None): + Initializes a new instance of the MathDataset class. + + __len__(self): + Returns the length of the dataset. + + __getitem__(self, idx): + Gets the item at the specified index from the dataset. + + """ + def __init__(self, image_paths, transform=None): + self.image_paths = image_paths + self.transform = transform + + def __len__(self): + return len(self.image_paths) + + def __getitem__(self, idx): + # if not pil image, then convert to pil image + if isinstance(self.image_paths[idx], str): + raw_image = Image.open(self.image_paths[idx]) + else: + raw_image = self.image_paths[idx] + if self.transform: + image = self.transform(raw_image) + return image + + +class FormulaProcessor: + """ + Initializes a new instance of the FormulaProcessor class. + + Parameters: + + - config_path (str): The path to the configuration file. If not provided, the default configuration file will be used. + """ + def __init__(self, config_path: str = None): + """ + This class represents a software developer. It initializes the developer object with a given config_path or loads the default configuration if no path is provided. It also initializes the mfd_model, mfr_model, mfr_transform, latex_filling_list, and mf_image_list properties. + + Attributes: + - config: The configuration loaded from the config_path or default configuration. + - mfd_model: The model used for mfd transformation. + - mfr_model: The model used for mfr transformation. + - mfr_transform: The transformation object used for mfr transformation. + - latex_filling_list: A list to store latex filling data. + - mf_image_list: A list to store MF images. + + Methods: + - __init__: Initializes the developer object with the provided config_path or default configuration. + + Parameters: + - config_path (str): The path to the configuration file (optional). + + Example usage: + developer = Developer() + developer = Developer("path/to/config") + + Note: + - The config_path parameter is optional. If not provided, the default configuration will be used. + - The load_config function is used internally to load the configuration from the provided path or default configuration. + - The _init_mfd_model, _init_mfr_model, and _init_mfr_transform methods are used internally to initialize the mfd_model, mfr_model, and mfr_transform properties respectively. + - The latex_filling_list and mf_image_list properties are empty lists to start with. + """ + self.config = load_config(config_path) if config_path else load_config() + self.mfd_model = self._init_mfd_model() + self.mfr_model, self.mfr_transform = self._init_mfr_model() + self.latex_filling_list = [] + self.mf_image_list = [] + + def _init_mfd_model(self): + """ + Initializes the MFD (Multiple Feature Detection) model. + + This method initializes the MFD model by setting the weight of the model from the configuration file and creating a new instance of the YOLO class. + + Returns: + mfd_model (YOLO): The initialized MFD model. + + """ + weight = self.config['model_args']['mfd_weight'] + mfd_model = YOLO(weight) + return mfd_model + + def _init_mfr_model(self) -> Tuple[torch.nn.Module, transforms.Compose]: + """ + Initializes the MFR model by loading the weights and setting the device. + + Returns: + A tuple containing the MFR model and the transformation to apply to input images. + + Parameters: + None + + Returns: + Tuple: A tuple containing the MFR model (`torch.nn.Module`) and the transformation (`transforms.Compose`). + """ + weight_dir = self.config['model_args']['mfr_weight'] + device = self.config['model_args']['device'] + + args = argparse.Namespace(cfg_path="modules/UniMERNet/configs/demo.yaml", options=None) + cfg = Config(args) + cfg.config.model.pretrained = os.path.join(weight_dir, "pytorch_model.bin") + cfg.config.model.model_config.model_name = weight_dir + cfg.config.model.tokenizer_config.path = weight_dir + task = tasks.setup_task(cfg) + model = task.build_model(cfg) + model = model.to(device) + vis_processor = load_processor('formula_image_eval', cfg.config.datasets.formula_rec_eval.vis_processor.eval) + mfr_transform = transforms.Compose([vis_processor]) + return model, mfr_transform + + def detect_formulas(self, img_list: List, doc_layout_result: List[dict]) -> List[dict]: + """ + Detect formulas in the given list of images and update the document layout result with the detected formulas. + + Parameters: + - img_list: A list of images to detect formulas from. + - doc_layout_result: A list of dictionaries representing the document layout result. Each dictionary contains the layout details of a single page. + + Returns: + - A list of dictionaries representing the updated document layout result with the detected formulas. + + Example Usage: + + img_list = [image1, image2, image3] + doc_layout_result = [{'page_id': 1, 'layout_dets': []}, {'page_id': 2, 'layout_dets': []}] + detected_formulas = detect_formulas(img_list, doc_layout_result) + print(detected_formulas) + """ + img_size = self.config['model_args']['img_size'] + conf_thres = self.config['model_args']['conf_thres'] + iou_thres = self.config['model_args']['iou_thres'] + + logger.debug('Formula detection - init') + start = time.time() + + for idx, image in enumerate(img_list): + mfd_res = self.mfd_model.predict(image, imgsz=img_size, conf=conf_thres, iou=iou_thres, verbose=True)[0] + + for xyxy, conf, cla in zip(mfd_res.boxes.xyxy.cpu(), mfd_res.boxes.conf.cpu(), mfd_res.boxes.cls.cpu()): + xmin, ymin, xmax, ymax = [int(p.item()) for p in xyxy] + new_item = { + 'category_id': 13 + int(cla.item()), + 'poly': [xmin, ymin, xmax, ymin, xmax, ymax, xmin, ymax], + 'score': round(float(conf.item()), 2), + 'latex': '', + } + doc_layout_result[idx]['layout_dets'].append(new_item) + self.latex_filling_list.append(new_item) + bbox_img = get_croped_image(Image.fromarray(image), [xmin, ymin, xmax, ymax]) + self.mf_image_list.append(bbox_img) + + del mfd_res + torch.cuda.empty_cache() + gc.collect() + + logger.debug(f'Formula detection done in {round(time.time() - start, 2)}s!') + + return doc_layout_result + + def recognize_formulas(self, batch_size: int = 128): + """ + This method `recognize_formulas` is used to recognize formulas from a given batch of images. It takes an optional parameter `batch_size` which determines the number of images to process in each batch. The default value for `batch_size` is 128. + + The method starts by retrieving the device from the configuration settings. Then it logs a debug message indicating the start of formula recognition process and records the start time. + + Next, it creates a `MathDataset` object using the `mf_image_list` and `mfr_transform` as arguments. This dataset is then used to create a `DataLoader` object with the specified `batch_size` and 32 worker threads. + + The method initializes an empty list `mfr_res` to store the formula recognition results. + + It then iterates over the batches of images in the dataloader. In each iteration, it moves the images to the specified device. It then generates the formula predictions using the `mfr_model` by passing the images as input. The formula predictions are obtained from the `output` dictionary using the key `'pred_str'`. These predictions are then appended to the `mfr_res` list. + + Finally, it loops over the `latex_filling_list` and the `mfr_res` lists simultaneously. For each pair of items, it updates the `latex` property of the corresponding entry in the `res` dictionary by removing any leading or trailing white spaces. + + After processing all the batches, it logs an information message indicating the number of formulas and the total time taken for formula recognition. + + Note: The logger used in the code is assumed to be an instance of a logger class that supports the `debug` and `info` methods. The `logger` object is not defined in this code snippet. + + """ + device = self.config['model_args']['device'] + + logger.debug('Formula recognition') + start = time.time() + + dataset = MathDataset(self.mf_image_list, transform=self.mfr_transform) + dataloader = DataLoader(dataset, batch_size=batch_size, num_workers=32) + mfr_res = [] + for imgs in dataloader: + imgs = imgs.to(device) + output = self.mfr_model.generate({'image': imgs}) + mfr_res.extend(output['pred_str']) + for res, latex in zip(self.latex_filling_list, mfr_res): + res['latex'] = latex_rm_whitespace(latex) + + logger.info(f'Formula nums: {len(self.mf_image_list)} mfr time: {round(time.time() - start, 2)}') + + def detect_recognize_formulas(self, img_list: List, doc_layout_result: List[dict], batch_size: int = 128): + """ + Detect and recognize formulas in document layout results. + + This method takes a list of images, a list of document layout results, and an optional batch size. It detects formulas in the document layout results by calling the `detect_formulas` method. Then, it recognizes the detected formulas using the `recognize_formulas` method. Finally, it returns the updated document layout results. + + Parameters: + - `img_list` (List): A list of images. + - `doc_layout_result` (List[dict]): A list of document layout results. + - `batch_size` (int, optional): The batch size for recognition. Defaults to 128. + + Returns: + - `doc_layout_result` (List[dict]): The updated document layout results. + + """ + doc_layout_result = self.detect_formulas(img_list, doc_layout_result) + self.recognize_formulas(batch_size) + return doc_layout_result + + def clear_memory(self): + """ + Clears the models from memory, freeing up resources. + """ + logger.info('Clearing models from memory.') + if self.mfd_model is not None: + del self.mfd_model + self.mfd_model = None + + if self.mfr_model is not None: + del self.mfr_model + self.mfr_model = None + + torch.cuda.empty_cache() + gc.collect() + logger.info('Models successfully cleared from memory.') diff --git a/app_tools/layout_analysis.py b/app_tools/layout_analysis.py new file mode 100644 index 0000000..48af815 --- /dev/null +++ b/app_tools/layout_analysis.py @@ -0,0 +1,146 @@ +import time +import gc +import torch +from typing import Optional + +from modules.layoutlmv3.model_init import Layoutlmv3_Predictor +from app_tools.config import setup_logging, load_config + + +class LayoutAnalyzer: + """ + class LayoutAnalyzer: + This class analyzes the layout of documents by detecting the layout of each page in a document image. + + Attributes: + logger: The logger object for logging debug, info, and error messages. + config: The configuration settings for the layout analysis. + model: The layout detection model. + + Methods: + __init__(self, config_path: Optional[str] = None) + Constructs a LayoutAnalyzer object. + + _init_model(self) -> Layoutlmv3_Predictor + Initializes the layout detection model. + + detect_layout(self, img_list: list) -> list + Detects the layout of multiple images. + + clear_model(self) + Clears the layout detection model from memory. + """ + def __init__(self, config_path: Optional[str] = None): + """ + Initializes an instance of the software developer class. + + Args: + config_path (Optional[str]): The path to the configuration file. Defaults to None. + + Attributes: + logger: The logger instance for logging messages. + config: The configuration settings loaded from the configuration file. + model: The initialized model for the software developer. + + """ + self.logger = setup_logging('layout_analysis') + self.config = load_config(config_path) if config_path else load_config() + self.model = self._init_model() + + def _init_model(self) -> Layoutlmv3_Predictor: + """ + Initializes and returns an instance of the `Layoutlmv3_Predictor` class. + + Parameters: + - self: The current object. + + Returns: + A `Layoutlmv3_Predictor` object initialized with the specified `weight` value from the configuration. + + """ + weight = self.config['model_args']['layout_weight'] + model = Layoutlmv3_Predictor(weight) + return model + + def detect_layout(self, img_list: list) -> list: + """ + This method `detect_layout` is used to detect the layout of a list of images. + + Parameters: + - `img_list`: A list of images to detect the layout from. + + Returns: + - A list of layout results. + + Raises: + - `ValueError`: If the model is not initialized. Please call `init_model` before `detect_layout`. + + The method performs the following steps: + 1. It checks if the model is initialized. If not, it raises a `ValueError`. + 2. It initializes an empty list `doc_layout_result` to store the layout results. + 3. It logs a debug message indicating the start of layout detection. + 4. It starts a timer to measure the time taken for layout detection. + 5. It iterates over each image in the `img_list`. + a. It gets the height and width of the image. + b. It passes the image to the model for layout detection. + c. It adds additional information to the layout result, such as page number, height, and width. + d. It appends the layout result to the `doc_layout_result` list. + e. It deletes the layout result and clears the GPU memory. + 6. It logs a debug message indicating the completion of layout detection and the time taken. + + Example usage: + ``` + layout_detector = LayoutDetector() + layout_detector.init_model() + results = layout_detector.detect_layout([image1, image2, image3]) + ``` + """ + if self.model is None: + raise ValueError("Model is not initialized. Please call `init_model` before `detect_layout`.") + + doc_layout_result = [] + + self.logger.debug('Layout detection - init') + start = time.time() + + for idx, image in enumerate(img_list): + img_H, img_W = image.shape[0], image.shape[1] + + layout_res = self.model(image, ignore_catids=[]) + + layout_res['page_info'] = { + 'page_no': idx, + 'height': img_H, + 'width': img_W + } + doc_layout_result.append(layout_res) + + del layout_res + torch.cuda.empty_cache() + gc.collect() + + self.logger.debug(f'Layout detection done in {round(time.time() - start, 2)}s!') + + return doc_layout_result + + def clear_model(self): + """ + This method clears the model from memory by deleting the model object and freeing up GPU memory using torch.cuda.empty_cache(). It also collects garbage to release any unreferenced memory. + + This method does not take any input parameters. + + This method does not return any values. + + Example usage: + obj.clear_model() + + """ + self.logger.info('Clearing the model from memory.') + + if self.model is not None: + del self.model + self.model = None + + torch.cuda.empty_cache() + gc.collect() + self.logger.info('Model successfully cleared from memory.') diff --git a/app_tools/ocr_analysis.py b/app_tools/ocr_analysis.py new file mode 100644 index 0000000..305853a --- /dev/null +++ b/app_tools/ocr_analysis.py @@ -0,0 +1,100 @@ +import time +import cv2 +import numpy as np +from PIL import Image + +from modules.self_modify import ModifiedPaddleOCR + +from app_tools.config import setup_logging + + +class OCRProcessor: + """ + This class represents an OCR Processor. It is responsible for performing OCR recognition on a list of images based on certain conditions defined in the code. + + Attributes: + logger: Logger object for logging OCR analysis. + ocr_model: Instance of the ModifiedPaddleOCR class used for OCR recognition. + + Methods: + __init__(self, show_log: bool = True) + Initializes the OCRProcessor object with a logger and an instance of the ModifiedPaddleOCR class. + + recognize_ocr(self, img_list: list, doc_layout_result: list) -> list: + Performs OCR recognition on a list of images based on the given document layout results. + Returns a modified document layout result list with any newly recognized text appended to it. + """ + def __init__(self, show_log: bool = True): + """ + This class is responsible for initializing the OCR Analysis object. + + Attributes: + show_log (bool): A boolean value indicating whether to display log messages. Default is True. + logger: The logger object used for logging. + ocr_model: An instance of the ModifiedPaddleOCR class. + """ + self.logger = setup_logging('ocr_analysis') + self.ocr_model = ModifiedPaddleOCR(show_log=show_log) + + def recognize_ocr(self, img_list: list, doc_layout_result: list) -> list: + """ + This method `recognize_ocr` performs Optical Character Recognition (OCR) on a list of images and appends the recognized text to the document layout result. + + Parameters: + - `img_list` (list): A list of images in numpy array format. + - `doc_layout_result` (list): A list containing the document layout results, each result representing a page in the document. Each page result should have a 'layout_dets' field. + + Returns: + - `doc_layout_result` (list): The updated document layout result list with recognized text appended. + + The method first converts each input image from the RGB color space to the BGR color space using OpenCV's `cv2.cvtColor` method. It then iterates over each image and its corresponding layout details in the document layout result. + + For each layout detail, the method checks if the category ID is either 13 or 14, which correspond to formula categories. If found, the bounding box coordinates of the layout detail are extracted and added to the `single_page_mfdetrec_res` list. + + Next, the method checks if the category ID is one of [0, 1, 2, 4, 6, 7], which represent categories that require OCR. If found, the bounding box coordinates are extracted, and a region of interest (ROI) is cropped from the image using the `pil_img.crop` method. This ROI image is then converted back to the BGR color space. + + The `self.ocr_model.ocr` method is then called, passing the cropped image along with the `single_page_mfdetrec_res` list, to perform OCR. The OCR result is obtained as a list of bounding boxes and their corresponding recognized text. + + If the OCR result is not empty, the method iterates over each bounding box and text pair in the result. The four corner points of the bounding box are extracted, along with the confidence score and the recognized text. A new layout detail is created with a category ID of 15 (corresponding to recognized text), and this detail is added to the `doc_layout_result`. + + Finally, the method logs the time taken for OCR recognition and returns the updated `doc_layout_result` list. + """ + self.logger.debug('OCR recognition - init') + start = time.time() + + for idx, image in enumerate(img_list): + pil_img = Image.fromarray(cv2.cvtColor(image, cv2.COLOR_RGB2BGR)) + single_page_res = doc_layout_result[idx]['layout_dets'] + single_page_mfdetrec_res = [] + + for res in single_page_res: + if int(res['category_id']) in [13, 14]: # Categories for formula + xmin, ymin = int(res['poly'][0]), int(res['poly'][1]) + xmax, ymax = int(res['poly'][4]), int(res['poly'][5]) + single_page_mfdetrec_res.append({ + "bbox": [xmin, ymin, xmax, ymax], + }) + + for res in single_page_res: + if int(res['category_id']) in [0, 1, 2, 4, 6, 7]: # Categories that need OCR + xmin, ymin = int(res['poly'][0]), int(res['poly'][1]) + xmax, ymax = int(res['poly'][4]), int(res['poly'][5]) + crop_box = [xmin, ymin, xmax, ymax] + cropped_img = Image.new('RGB', pil_img.size, 'white') + cropped_img.paste(pil_img.crop(crop_box), crop_box) + cropped_img = cv2.cvtColor(np.asarray(cropped_img), cv2.COLOR_RGB2BGR) + ocr_res = self.ocr_model.ocr(cropped_img, mfd_res=single_page_mfdetrec_res)[0] + if ocr_res: + for box_ocr_res in ocr_res: + p1, p2, p3, p4 = box_ocr_res[0] + text, score = box_ocr_res[1] + doc_layout_result[idx]['layout_dets'].append({ + 'category_id': 15, + 'poly': p1 + p2 + p3 + p4, + 'score': round(score, 2), + 'text': text, + }) + + self.logger.info(f'OCR recognition done in: {round(time.time() - start, 2)}s') + + return doc_layout_result diff --git a/utils/pdf_tools.py b/app_tools/pdf.py similarity index 97% rename from utils/pdf_tools.py rename to app_tools/pdf.py index bab2e97..c43ef63 100644 --- a/utils/pdf_tools.py +++ b/app_tools/pdf.py @@ -2,14 +2,14 @@ from typing import List, Optional, Generator from modules.extract_pdf import load_pdf_fitz -from utils.config import load_config, setup_logging +from app_tools.config import load_config, setup_logging class PDFProcessor: """ Class PDFTools - This class provides a set of tools for working with PDF files. + This class provides a set of app_tools for working with PDF files. Methods: - __init__(config_path: Optional[str] = None): Initializes the PDFTools object. diff --git a/app_tools/table_analysis.py b/app_tools/table_analysis.py new file mode 100644 index 0000000..ce6dc73 --- /dev/null +++ b/app_tools/table_analysis.py @@ -0,0 +1,144 @@ +import time +import torch +import gc +from PIL import Image +from struct_eqtable import build_model + +from app_tools.config import load_config, setup_logging + + +class TableProcessor: + """ + This class represents a Table Processor that is used for table recognition in documents. The `TableProcessor` class has the following methods: + + - `__init__(self, config_path: str = None)`: Initializes a Table Processor object. It takes an optional `config_path` parameter which specifies the path to a configuration file. If no `config_path` is provided, the default configuration will be used. This method also initializes a logger and loads the configuration. It calls the `_init_tr_model` method to initialize the table recognition model. + + - `_init_tr_model(self)`: Initializes the table recognition model. It retrieves the model weight, maximum time, and device from the configuration. It then builds the model using the specified weight and maximum time. If the device is set to 'cuda', the model is moved to the GPU. The initialized table recognition model is returned. + + - `recognize_tables(self, img_list: list, doc_layout_result: list) -> list`: Performs table recognition on a list of images. It takes `img_list` as input, which is a list of images to process. It also takes `doc_layout_result`, which is a list containing layout results for each image. This method iterates over each image and its corresponding layout results. If a layout result has a 'category_id' of 5, indicating it is a table, the image is cropped and passed to the table recognition model. The output of the model is stored in the layout result as 'latex'. If the table recognition operation takes longer than the maximum time specified in the configuration, the layout result will have a 'timeout' flag set to True. The updated `doc_layout_result` is returned. + + - `clear_memory(self)`: Clears the table recognition model from memory. This method deletes the table recognition model, clears the GPU cache, and performs garbage collection to free up memory. + + Note: The code does not include the implementation of functions like `setup_logging`, `load_config`, `build_model`, and the import statements for the necessary libraries. + """ + def __init__(self, config_path: str = None): + """ + This class initializes an instance of the software with the provided configuration path. + + Attributes: + - logger: The logger instance for logging debug and error messages. + - config: The configuration object that stores the loaded configuration from the provided path. + - tr_model: The initialized text recognition model. + + Methods: + - __init__(self, config_path: str = None): Initializes an instance of the software with the provided configuration path. + - _init_tr_model(self): Initializes the text recognition model. + + Note: This class requires a logging setup function called 'setup_logging' to be defined and a config loading function called 'load_config' to be defined. + """ + self.logger = setup_logging('table_analysis') + self.config = load_config(config_path) if config_path else load_config() + self.tr_model = self._init_tr_model() + + def _init_tr_model(self): + """ + Initializes the translation model. + + This method initializes the translation model by setting the weight, maximum time, and device attributes based on the provided configuration. It also builds the model using the `build_model` function. + + Parameters: + - self : object + The instance of the class that this method is called upon. + + Returns: + - tr_model : object + The initialized translation model. + """ + weight = self.config['model_args']['tr_weight'] + max_time = self.config['model_args']['table_max_time'] + device = self.config['model_args']['device'] + + tr_model = build_model(weight, max_new_tokens=4096, max_time=max_time) + if device == 'cuda': + tr_model = tr_model.cuda() + return tr_model + + def recognize_tables(self, img_list: list, doc_layout_result: list) -> list: + """ + This method recognizes tables in a list of images based on the document layout results. + + Parameters: + - img_list: a list of images to perform table recognition on + - doc_layout_result: a list containing layout details of the document + + Returns: + - A modified version of doc_layout_result with table recognition results added + + The method initializes the table recognition process and sets the maximum time for the recognition. It then iterates through each image in img_list and retrieves the layout details for that image from doc_layout_result. + + For each layout detail, if the category_id is 5 (indicating that it is a table), the method crops the image based on the polygon coordinates of the layout detail and performs table recognition on the cropped image. + + The table recognition operation might take significant time, so a timeout check is performed to determine if the recognition process exceeds the maximum time. If it does, the timeout flag is set to True in the layout detail. + + The recognized LaTeX output is assigned to the "latex" property of the layout detail. + + Finally, the method logs the completion of the table recognition process and returns the modified doc_layout_result. + + Note: The method uses torch and PIL libraries for image processing and table recognition. + """ + max_time = self.config['model_args']['table_max_time'] + + self.logger.debug('Table recognition - init') + start_0 = time.time() + + for idx, image in enumerate(img_list): + pil_img = Image.fromarray(image) + single_page_res = doc_layout_result[idx]['layout_dets'] + + for jdx, res in enumerate(single_page_res): + if int(res['category_id']) == 5: # Perform table recognition + xmin, ymin = int(res['poly'][0]), int(res['poly'][1]) + xmax, ymax = int(res['poly'][4]), int(res['poly'][5]) + crop_box = [xmin, ymin, xmax, ymax] + cropped_img = pil_img.crop(crop_box) + + start = time.time() + with torch.no_grad(): + start_1 = time.time() + output = self.tr_model(cropped_img) # This operation might take significant time + self.logger.debug(f'{idx} - {jdx} tr_model generate in: {round(time.time() - start_1, 2)}s') + + if (time.time() - start) > max_time: + res["timeout"] = True + res["latex"] = output[0] + + self.logger.info(f'Table recognition done in: {round(time.time() - start_0, 2)}s') + + return doc_layout_result + + def clear_memory(self): + """ + Clears the table recognition model from memory. + + This method clears the table recognition model from memory by deleting the model object and releasing the memory occupied by the model. It also clears the CUDA cache and performs garbage collection. + + Parameters: + None + + Returns: + None + + Example: + clear_memory() + + """ + self.logger.info('Clearing the table recognition model from memory.') + + if self.tr_model is not None: + del self.tr_model + self.tr_model = None + + torch.cuda.empty_cache() + gc.collect() + self.logger.info('Table recognition model successfully cleared from memory.') + diff --git a/app_tools/utils.py b/app_tools/utils.py new file mode 100644 index 0000000..eca5268 --- /dev/null +++ b/app_tools/utils.py @@ -0,0 +1,18 @@ +import os +import json + + +def save_file(output_dir, single_pdf, doc_layout_result): + """ + Save the document layout result as a JSON file in the specified output directory. + + :param output_dir: The directory where the JSON file should be saved. + :param single_pdf: The path of the single PDF file. + :param doc_layout_result: The document layout result to be saved. + :return: The base name of the saved JSON file. + """ + os.makedirs(output_dir, exist_ok=True) + basename = os.path.basename(single_pdf)[0:-4] + with open(os.path.join(output_dir, f'{basename}.json'), 'w') as f: + json.dump(doc_layout_result, f) + return basename diff --git a/utils/visualize.py b/app_tools/visualize.py similarity index 72% rename from utils/visualize.py rename to app_tools/visualize.py index 5b44128..eba0683 100644 --- a/utils/visualize.py +++ b/app_tools/visualize.py @@ -4,7 +4,7 @@ from PIL import Image, ImageDraw, ImageFont from modules.latex2png import tex2pil, zhtext2pil -from utils.config import setup_logging +from app_tools.config import setup_logging # Apply the logging configuration logger = setup_logging('visualize') @@ -21,7 +21,27 @@ "ocr_text"] def get_visualize(img_list: list, doc_layout_result, render: bool, output_dir, basename): + """ + This function takes a list of images, the result of a document layout analysis, a boolean flag 'render', an output directory path, and a basename as input arguments. It generates visualizations of the document layout and saves them as a PDF file. + Parameters: + - img_list (list): A list of images. Each image should be a numpy array representing an image. + - doc_layout_result: The result of a document layout analysis. It should be a list of dictionaries, where each dictionary represents the layout details of a single page. Each dictionary should contain information such as the category ID, polygon coordinates, and text/latex content. + - render (bool): A boolean flag indicating whether to render the text/latex content in the visualizations. + - output_dir: The output directory where the PDF file will be saved. + - basename: The basename of the PDF file. + + Returns: + None + + Example Usage: + img_list = [image1, image2, ...] # list of images + doc_layout_result = [...] # list of layout dictionaries + render = True # or False + output_dir = '/path/to/output/directory' + basename = 'output' + get_visualize(img_list, doc_layout_result, render, output_dir, basename) + """ vis_pdf_result = [] for idx, image in enumerate(img_list): single_page_res = doc_layout_result[idx]['layout_dets'] diff --git a/utils/detection.py b/utils/detection.py deleted file mode 100644 index 568752a..0000000 --- a/utils/detection.py +++ /dev/null @@ -1,121 +0,0 @@ -import torch -import gc -import time -from PIL import Image - -from modules.post_process import get_croped_image -from utils.config import load_config, setup_logging - -# Apply the logging configuration -logger = setup_logging('detection') - - -# layout detection and formula detection -def layout_detection_and_formula(img_list, layout_model, mfd_model): - # img_list es similar a pag_pdf - - model_configs = load_config() - img_size = model_configs['model_args']['img_size'] - conf_thres = model_configs['model_args']['conf_thres'] - iou_thres = model_configs['model_args']['iou_thres'] - - doc_layout_result = [] - latex_filling_list = [] - mf_image_list = [] - for idx, image in enumerate(img_list): - - img_H, img_W = image.shape[0], image.shape[1] - - layout_res = layout_model(image, ignore_catids=[]) - - mfd_res = mfd_model.predict(image, imgsz=img_size, conf=conf_thres, iou=iou_thres, verbose=True)[0] - - for xyxy, conf, cla in zip(mfd_res.boxes.xyxy.cpu(), mfd_res.boxes.conf.cpu(), mfd_res.boxes.cls.cpu()): - xmin, ymin, xmax, ymax = [int(p.item()) for p in xyxy] - new_item = { - 'category_id': 13 + int(cla.item()), - 'poly': [xmin, ymin, xmax, ymin, xmax, ymax, xmin, ymax], - 'score': round(float(conf.item()), 2), - 'latex': '', - } - layout_res['layout_dets'].append(new_item) - latex_filling_list.append(new_item) - bbox_img = get_croped_image(Image.fromarray(image), [xmin, ymin, xmax, ymax]) - mf_image_list.append(bbox_img) - - layout_res['page_info'] = dict( - page_no=idx, - height=img_H, - width=img_W - ) - doc_layout_result.append(layout_res) - - del mfd_res - torch.cuda.empty_cache() - gc.collect() - - return doc_layout_result, latex_filling_list, mf_image_list - - -def layout_detection(img_list, layout_model): - doc_layout_result = [] - - logger.debug('layout detection - init') - start = time.time() - - for idx, image in enumerate(img_list): - img_H, img_W = image.shape[0], image.shape[1] - - layout_res = layout_model(image, ignore_catids=[]) - - layout_res['page_info'] = dict( - page_no=idx, - height=img_H, - width=img_W - ) - doc_layout_result.append(layout_res) - - del layout_res - torch.cuda.empty_cache() - gc.collect() - - logger.debug(f'Layout detection done in {round(time.time() - start, 2)}s!') - - return doc_layout_result - - -def formula_detection(img_list, doc_layout_result, mfd_model): - model_configs = load_config() - img_size = model_configs['model_args']['img_size'] - conf_thres = model_configs['model_args']['conf_thres'] - iou_thres = model_configs['model_args']['iou_thres'] - - logger.debug('formula detection - init') - start = time.time() - - latex_filling_list = [] - mf_image_list = [] - for idx, image in enumerate(img_list): - - mfd_res = mfd_model.predict(image, imgsz=img_size, conf=conf_thres, iou=iou_thres, verbose=True)[0] - - for xyxy, conf, cla in zip(mfd_res.boxes.xyxy.cpu(), mfd_res.boxes.conf.cpu(), mfd_res.boxes.cls.cpu()): - xmin, ymin, xmax, ymax = [int(p.item()) for p in xyxy] - new_item = { - 'category_id': 13 + int(cla.item()), - 'poly': [xmin, ymin, xmax, ymin, xmax, ymax, xmin, ymax], - 'score': round(float(conf.item()), 2), - 'latex': '', - } - doc_layout_result[idx]['layout_dets'].append(new_item) - latex_filling_list.append(new_item) - bbox_img = get_croped_image(Image.fromarray(image), [xmin, ymin, xmax, ymax]) - mf_image_list.append(bbox_img) - - del mfd_res - torch.cuda.empty_cache() - gc.collect() - - logger.debug(f'Formula detection done in {round(time.time() - start, 2)}s!') - - return doc_layout_result, latex_filling_list, mf_image_list diff --git a/utils/model_tools.py b/utils/model_tools.py deleted file mode 100644 index 1468b11..0000000 --- a/utils/model_tools.py +++ /dev/null @@ -1,60 +0,0 @@ -import os -import argparse - -from torchvision import transforms -from ultralytics import YOLO -from unimernet.common.config import Config -import unimernet.tasks as tasks -from unimernet.processors import load_processor -from struct_eqtable import build_model - -from modules.layoutlmv3.model_init import Layoutlmv3_Predictor -from utils.config import load_config - - -def mfd_model_init(): - model_configs = load_config() - weight = model_configs['model_args']['mfd_weight'] - mfd_model = YOLO(weight) - return mfd_model - - -def mfr_model_init(): - model_configs = load_config() - weight_dir = model_configs['model_args']['mfr_weight'] - device = model_configs['model_args']['device'] - - args = argparse.Namespace(cfg_path="modules/UniMERNet/configs/demo.yaml", options=None) - cfg = Config(args) - cfg.config.model.pretrained = os.path.join(weight_dir, "pytorch_model.bin") - cfg.config.model.model_config.model_name = weight_dir - cfg.config.model.tokenizer_config.path = weight_dir - task = tasks.setup_task(cfg) - model = task.build_model(cfg) - model = model.to(device) - vis_processor = load_processor('formula_image_eval', cfg.config.datasets.formula_rec_eval.vis_processor.eval) - mfr_transform = transforms.Compose([vis_processor, ]) - return model, mfr_transform - - -def layout_model_init(): - model_configs = load_config() - weight = model_configs['model_args']['layout_weight'] - - model = Layoutlmv3_Predictor(weight) - return model - - -def tr_model_init(): - model_configs = load_config() - weight = model_configs['model_args']['tr_weight'] - max_time = model_configs['model_args']['table_max_time'] - device = model_configs['model_args']['device'] - - tr_model = build_model(weight, max_new_tokens=4096, max_time=max_time) - if device == 'cuda': - tr_model = tr_model.cuda() - return tr_model - - - diff --git a/utils/recognition.py b/utils/recognition.py deleted file mode 100644 index 4969031..0000000 --- a/utils/recognition.py +++ /dev/null @@ -1,182 +0,0 @@ -import time -import cv2 -import numpy as np -from PIL import Image -import torch -from torch.utils.data import Dataset, DataLoader - -from modules.post_process import latex_rm_whitespace - -from utils.config import load_config, setup_logging - -# Apply the logging configuration -logger = setup_logging('recognition') - - -class MathDataset(Dataset): - def __init__(self, image_paths, transform=None): - self.image_paths = image_paths - self.transform = transform - - def __len__(self): - return len(self.image_paths) - - def __getitem__(self, idx): - # if not pil image, then convert to pil image - if isinstance(self.image_paths[idx], str): - raw_image = Image.open(self.image_paths[idx]) - else: - raw_image = self.image_paths[idx] - if self.transform: - image = self.transform(raw_image) - return image - - -def formula_recognition(mf_image_list, latex_filling_list, mfr_model, mfr_transform, batch_size: int = 128): - # Formula recognition, collect all formula images in whole pdf file, then batch infer them. - model_configs = load_config() - device = model_configs['model_args']['device'] - - logger.debug('Formula recognition') - start = time.time() - - dataset = MathDataset(mf_image_list, transform=mfr_transform) - dataloader = DataLoader(dataset, batch_size=batch_size, num_workers=32) - mfr_res = [] - for imgs in dataloader: - imgs = imgs.to(device) - output = mfr_model.generate({'image': imgs}) - mfr_res.extend(output['pred_str']) - for res, latex in zip(latex_filling_list, mfr_res): - res['latex'] = latex_rm_whitespace(latex) - - logger.info(f'formula nums: {len(mf_image_list)} mfr time: {round(time.time() - start, 2)}') - - -def ocr_table_recognition(img_list: list, doc_layout_result, ocr_model, tr_model): - model_configs = load_config() - max_time = model_configs['model_args']['table_max_time'] - - logger.debug('ocr & table recognition') - start = time.time() - - for idx, image in enumerate(img_list): - pil_img = Image.fromarray(cv2.cvtColor(image, cv2.COLOR_RGB2BGR)) - single_page_res = doc_layout_result[idx]['layout_dets'] - single_page_mfdetrec_res = [] - for res in single_page_res: - if int(res['category_id']) in [13, 14]: # categories formula - xmin, ymin = int(res['poly'][0]), int(res['poly'][1]) - xmax, ymax = int(res['poly'][4]), int(res['poly'][5]) - single_page_mfdetrec_res.append({ - "bbox": [xmin, ymin, xmax, ymax], - }) - for res in single_page_res: - if int(res['category_id']) in [0, 1, 2, 4, 6, 7]: # categories that need to do ocr - xmin, ymin = int(res['poly'][0]), int(res['poly'][1]) - xmax, ymax = int(res['poly'][4]), int(res['poly'][5]) - crop_box = [xmin, ymin, xmax, ymax] - cropped_img = Image.new('RGB', pil_img.size, 'white') - cropped_img.paste(pil_img.crop(crop_box), crop_box) - cropped_img = cv2.cvtColor(np.asarray(cropped_img), cv2.COLOR_RGB2BGR) - ocr_res = ocr_model.ocr(cropped_img, mfd_res=single_page_mfdetrec_res)[0] - if ocr_res: - for box_ocr_res in ocr_res: - p1, p2, p3, p4 = box_ocr_res[0] - text, score = box_ocr_res[1] - doc_layout_result[idx]['layout_dets'].append({ - 'category_id': 15, - 'poly': p1 + p2 + p3 + p4, - 'score': round(score, 2), - 'text': text, - }) - elif int(res['category_id']) == 5: # do table recognition - xmin, ymin = int(res['poly'][0]), int(res['poly'][1]) - xmax, ymax = int(res['poly'][4]), int(res['poly'][5]) - crop_box = [xmin, ymin, xmax, ymax] - cropped_img = pil_img.convert("RGB").crop(crop_box) - - start = time.time() - with torch.no_grad(): - output = tr_model(cropped_img) - end = time.time() - if (end - start) > max_time: - res["timeout"] = True - res["latex"] = output[0] - - logger.info(f'ocr and table recognition done in: {round(time.time() - start, 2)}') - - return doc_layout_result - - -def ocr_recognition(img_list: list, doc_layout_result, ocr_model): - logger.debug('ocr recognition') - start = time.time() - - for idx, image in enumerate(img_list): - pil_img = Image.fromarray(cv2.cvtColor(image, cv2.COLOR_RGB2BGR)) - single_page_res = doc_layout_result[idx]['layout_dets'] - single_page_mfdetrec_res = [] - for res in single_page_res: - if int(res['category_id']) in [13, 14]: # categories formula - xmin, ymin = int(res['poly'][0]), int(res['poly'][1]) - xmax, ymax = int(res['poly'][4]), int(res['poly'][5]) - single_page_mfdetrec_res.append({ - "bbox": [xmin, ymin, xmax, ymax], - }) - for res in single_page_res: - if int(res['category_id']) in [0, 1, 2, 4, 6, 7]: # categories that need to do ocr - xmin, ymin = int(res['poly'][0]), int(res['poly'][1]) - xmax, ymax = int(res['poly'][4]), int(res['poly'][5]) - crop_box = [xmin, ymin, xmax, ymax] - cropped_img = Image.new('RGB', pil_img.size, 'white') - cropped_img.paste(pil_img.crop(crop_box), crop_box) - cropped_img = cv2.cvtColor(np.asarray(cropped_img), cv2.COLOR_RGB2BGR) - ocr_res = ocr_model.ocr(cropped_img, mfd_res=single_page_mfdetrec_res)[0] - if ocr_res: - for box_ocr_res in ocr_res: - p1, p2, p3, p4 = box_ocr_res[0] - text, score = box_ocr_res[1] - doc_layout_result[idx]['layout_dets'].append({ - 'category_id': 15, - 'poly': p1 + p2 + p3 + p4, - 'score': round(score, 2), - 'text': text, - }) - - logger.info(f'ocr recognition done in: {round(time.time() - start, 2)}') - - return doc_layout_result - - -def table_recognition(img_list, doc_layout_result, tr_model): - model_configs = load_config() - max_time = model_configs['model_args']['table_max_time'] - - logger.debug('table recognition') - start_0 = time.time() - - for idx, image in enumerate(img_list): - pil_img = Image.fromarray(image) - single_page_res = doc_layout_result[idx]['layout_dets'] - - for jdx, res in enumerate(single_page_res): - if int(res['category_id']) == 5: # do table recognition - xmin, ymin = int(res['poly'][0]), int(res['poly'][1]) - xmax, ymax = int(res['poly'][4]), int(res['poly'][5]) - crop_box = [xmin, ymin, xmax, ymax] - cropped_img = pil_img.crop(crop_box) - - start = time.time() - with torch.no_grad(): - start_1 = time.time() - output = tr_model(cropped_img) # It takes a lot of time - logger.debug(f'{idx} - {jdx} tr_model generate in: {time.time() - start_1}s') - - if (time.time() - start) > max_time: - res["timeout"] = True - res["latex"] = output[0] - - logger.info(f'table recognition done in: {round(time.time() - start_0, 2)}') - - return doc_layout_result \ No newline at end of file From bacc328861b1db7c534068a0e3b2672578f7b1cd Mon Sep 17 00:00:00 2001 From: angelgarcia Date: Wed, 4 Sep 2024 09:24:49 +0000 Subject: [PATCH 7/8] Refactor, optimize & doc various Python modules Refactored several Python modules to simplify documentation strings and improve readability. Added argparse to app.py for better handling of command line arguments. Improved error handling and logging in several files. Revised documentation. --- app.py | 31 +++++++----- app_tools/config.py | 4 +- app_tools/formula_analysis.py | 93 +++++++++++++---------------------- app_tools/layout_analysis.py | 22 +++------ app_tools/ocr_analysis.py | 32 ++++++------ app_tools/pdf.py | 9 ---- app_tools/table_analysis.py | 44 +++++++---------- app_tools/utils.py | 13 +++-- app_tools/visualize.py | 41 +++++++++------ 9 files changed, 127 insertions(+), 162 deletions(-) diff --git a/app.py b/app.py index 01749a6..025a2ea 100644 --- a/app.py +++ b/app.py @@ -1,5 +1,6 @@ # refactoring pdf_extract.py import time +import argparse from app_tools.config import setup_logging from app_tools.pdf import PDFProcessor @@ -14,13 +15,14 @@ if __name__ == '__main__': - - # Params - pdf_path: str = '1706.03762.pdf' - output_dir: str = 'output' - batch_size: int = 128 - vis: bool = False - render: bool = False + parser = argparse.ArgumentParser(description="Process PDF files and render output images.") + parser.add_argument('--pdf', type=str, required=True, help="Path to the input PDF file") + parser.add_argument('--output', type=str, default="output", help="Output directory or filename prefix (default: 'output')") + parser.add_argument('--batch-size', type=int, default=128, help="Batch size for processing (default: 128)") + parser.add_argument('--vis', action='store_true', help="Enable visualization mode") + parser.add_argument('--render', action='store_true', help="Enable rendering mode") + args = parser.parse_args() + logger.info("Arguments: %s", args) logger.info('Started!') start = time.time() @@ -34,20 +36,23 @@ start = time.time() pdf_processor = PDFProcessor() - all_pdfs = pdf_processor.check_pdf(pdf_path) + all_pdfs = pdf_processor.check_pdf(args.pdf) for idx, single_pdf, img_list in pdf_processor.process_all_pdfs(all_pdfs): doc_layout_result = analyzer.detect_layout(img_list) - doc_layout_result = formulas.detect_recognize_formulas(img_list, doc_layout_result) + doc_layout_result = formulas.detect_recognize_formulas(img_list, doc_layout_result, args.batch_size) doc_layout_result = ocr_processor.recognize_ocr(img_list, doc_layout_result) doc_layout_result = table_processor.recognize_tables(img_list, doc_layout_result) - basename = save_file(output_dir, single_pdf, doc_layout_result) + basename = save_file(args.output, single_pdf, doc_layout_result) logger.debug(f'Save file: {basename}.json') - if vis: - get_visualize(img_list, doc_layout_result, render, output_dir, basename) + if args.vis: + logger.info("Visualization mode enabled") + get_visualize(img_list, doc_layout_result, args.render, args.output, basename) + else: + logger.info("Visualization mode disabled") logger.info(f'Finished! time cost: {int(time.time() - start)} s') - logger.info('----------------------------------------') \ No newline at end of file + logger.info('----------------------------------------') diff --git a/app_tools/config.py b/app_tools/config.py index 6eb01cc..f1e3a09 100644 --- a/app_tools/config.py +++ b/app_tools/config.py @@ -12,7 +12,7 @@ ################### MODEL CONFIGS ################### def load_config(): - with open('configs/model_configs.yaml') as f: + with open(model_configs_path) as f: model_configs = yaml.load(f, Loader=yaml.FullLoader) return model_configs @@ -22,7 +22,7 @@ def load_config(): log_file_path = os.path.join(parent_dir, 'app_logs.log') # TODO: Add to config file -TIMEZONE: str = 'Europe/Madrid'# "Asia/Shanghai" +TIMEZONE: str = "Asia/Shanghai" # 'Europe/Madrid' timezone = pytz.timezone(TIMEZONE) # Specify your time zone here diff --git a/app_tools/formula_analysis.py b/app_tools/formula_analysis.py index 9266cee..55ab737 100644 --- a/app_tools/formula_analysis.py +++ b/app_tools/formula_analysis.py @@ -10,7 +10,7 @@ from ultralytics import YOLO from unimernet.common.config import Config import unimernet.tasks as tasks -from unimernet.processors import load_processor +from unimernet.processors import load_processor ## TODO: WARNING 'load_processor' is not declared in __all__ from modules.post_process import get_croped_image, latex_rm_whitespace from app_tools.config import load_config, setup_logging @@ -18,26 +18,6 @@ logger = setup_logging('formula_analysis') class MathDataset(Dataset): - """ - MathDataset class - - A class representing a dataset for mathematical operations. - - Attributes: - image_paths (list): A list of image paths. - transform (callable): A function or transformation to apply to the images. - - Methods: - __init__(self, image_paths, transform=None): - Initializes a new instance of the MathDataset class. - - __len__(self): - Returns the length of the dataset. - - __getitem__(self, idx): - Gets the item at the specified index from the dataset. - - """ def __init__(self, image_paths, transform=None): self.image_paths = image_paths self.transform = transform @@ -46,27 +26,33 @@ def __len__(self): return len(self.image_paths) def __getitem__(self, idx): + if idx >= len(self.image_paths) or idx < 0: + raise IndexError("Index out of range") + # if not pil image, then convert to pil image if isinstance(self.image_paths[idx], str): - raw_image = Image.open(self.image_paths[idx]) + try: + raw_image = Image.open(self.image_paths[idx]) + except IOError as e: + raise IOError(f"Error opening image: {self.image_paths[idx]}") from e else: raw_image = self.image_paths[idx] + + # apply transformation if any if self.transform: - image = self.transform(raw_image) - return image + return self.transform(raw_image) + return raw_image class FormulaProcessor: """ - Initializes a new instance of the FormulaProcessor class. + The FormulaProcessor class is designed to handle formula detection and recognition in images. + It is initialized with an optional configuration file and provides methods to detect and recognize formulas in images. - Parameters: - - - config_path (str): The path to the configuration file. If not provided, the default configuration file will be used. """ def __init__(self, config_path: str = None): """ - This class represents a software developer. It initializes the developer object with a given config_path or loads the default configuration if no path is provided. It also initializes the mfd_model, mfr_model, mfr_transform, latex_filling_list, and mf_image_list properties. + It initializes the mfd_model, mfr_model, mfr_transform, latex_filling_list, and mf_image_list properties. Attributes: - config: The configuration loaded from the config_path or default configuration. @@ -82,10 +68,6 @@ def __init__(self, config_path: str = None): Parameters: - config_path (str): The path to the configuration file (optional). - Example usage: - developer = Developer() - developer = Developer("path/to/config") - Note: - The config_path parameter is optional. If not provided, the default configuration will be used. - The load_config function is used internally to load the configuration from the provided path or default configuration. @@ -116,12 +98,6 @@ def _init_mfr_model(self) -> Tuple[torch.nn.Module, transforms.Compose]: """ Initializes the MFR model by loading the weights and setting the device. - Returns: - A tuple containing the MFR model and the transformation to apply to input images. - - Parameters: - None - Returns: Tuple: A tuple containing the MFR model (`torch.nn.Module`) and the transformation (`transforms.Compose`). """ @@ -146,17 +122,11 @@ def detect_formulas(self, img_list: List, doc_layout_result: List[dict]) -> List Parameters: - img_list: A list of images to detect formulas from. - - doc_layout_result: A list of dictionaries representing the document layout result. Each dictionary contains the layout details of a single page. + - doc_layout_result: A list of dictionaries representing the document layout result. + Each dictionary contains the layout details of a single page. Returns: - A list of dictionaries representing the updated document layout result with the detected formulas. - - Example Usage: - - img_list = [image1, image2, image3] - doc_layout_result = [{'page_id': 1, 'layout_dets': []}, {'page_id': 2, 'layout_dets': []}] - detected_formulas = detect_formulas(img_list, doc_layout_result) - print(detected_formulas) """ img_size = self.config['model_args']['img_size'] conf_thres = self.config['model_args']['conf_thres'] @@ -191,22 +161,24 @@ def detect_formulas(self, img_list: List, doc_layout_result: List[dict]) -> List def recognize_formulas(self, batch_size: int = 128): """ - This method `recognize_formulas` is used to recognize formulas from a given batch of images. It takes an optional parameter `batch_size` which determines the number of images to process in each batch. The default value for `batch_size` is 128. - - The method starts by retrieving the device from the configuration settings. Then it logs a debug message indicating the start of formula recognition process and records the start time. + This method is used to recognize formulas in a batch of images. - Next, it creates a `MathDataset` object using the `mf_image_list` and `mfr_transform` as arguments. This dataset is then used to create a `DataLoader` object with the specified `batch_size` and 32 worker threads. + This method performs formula recognition by iterating over a dataset of images. + It uses a pre-trained model to generate predictions for each image in the dataset. + The recognized formulas are then stored in a list. + Finally, the method logs the number of formulas recognized and the time taken for formula recognition. - The method initializes an empty list `mfr_res` to store the formula recognition results. + The method takes an optional argument `batch_size` that specifies the number of images to process in each batch. + This can be useful for managing memory usage. The default batch size is 128. - It then iterates over the batches of images in the dataloader. In each iteration, it moves the images to the specified device. It then generates the formula predictions using the `mfr_model` by passing the images as input. The formula predictions are obtained from the `output` dictionary using the key `'pred_str'`. These predictions are then appended to the `mfr_res` list. - - Finally, it loops over the `latex_filling_list` and the `mfr_res` lists simultaneously. For each pair of items, it updates the `latex` property of the corresponding entry in the `res` dictionary by removing any leading or trailing white spaces. - - After processing all the batches, it logs an information message indicating the number of formulas and the total time taken for formula recognition. + Parameters: + - batch_size: An integer specifying the batch size. Default is 128. - Note: The logger used in the code is assumed to be an instance of a logger class that supports the `debug` and `info` methods. The `logger` object is not defined in this code snippet. + Returns: + None + Note: + - The method assumes that the pre-trained model and the image dataset have already been initialized and assigned to the appropriate instance variables. """ device = self.config['model_args']['device'] @@ -229,7 +201,10 @@ def detect_recognize_formulas(self, img_list: List, doc_layout_result: List[dict """ Detect and recognize formulas in document layout results. - This method takes a list of images, a list of document layout results, and an optional batch size. It detects formulas in the document layout results by calling the `detect_formulas` method. Then, it recognizes the detected formulas using the `recognize_formulas` method. Finally, it returns the updated document layout results. + This method takes a list of images, a list of document layout results, and an optional batch size. + It detects formulas in the document layout results by calling the `detect_formulas` method. + Then, it recognizes the detected formulas using the `recognize_formulas` method. + Finally, it returns the updated document layout results. Parameters: - `img_list` (List): A list of images. diff --git a/app_tools/layout_analysis.py b/app_tools/layout_analysis.py index 48af815..eb9f920 100644 --- a/app_tools/layout_analysis.py +++ b/app_tools/layout_analysis.py @@ -32,16 +32,10 @@ class LayoutAnalyzer: """ def __init__(self, config_path: Optional[str] = None): """ - Initializes an instance of the software developer class. + Initializes an instance and init model. Args: config_path (Optional[str]): The path to the configuration file. Defaults to None. - - Attributes: - logger: The logger instance for logging messages. - config: The configuration settings loaded from the configuration file. - model: The initialized model for the software developer. - """ self.logger = setup_logging('layout_analysis') self.config = load_config(config_path) if config_path else load_config() @@ -104,14 +98,14 @@ def detect_layout(self, img_list: list) -> list: start = time.time() for idx, image in enumerate(img_list): - img_H, img_W = image.shape[0], image.shape[1] + img_h, img_w = image.shape[0], image.shape[1] layout_res = self.model(image, ignore_catids=[]) layout_res['page_info'] = { 'page_no': idx, - 'height': img_H, - 'width': img_W + 'height': img_h, + 'width': img_w } doc_layout_result.append(layout_res) @@ -125,15 +119,11 @@ def detect_layout(self, img_list: list) -> list: def clear_model(self): """ - This method clears the model from memory by deleting the model object and freeing up GPU memory using torch.cuda.empty_cache(). It also collects garbage to release any unreferenced memory. - - This method does not take any input parameters. - - This method does not return any values. + This method clears the model from memory by deleting the model object and freeing up GPU memory using torch.cuda.empty_cache(). + It also collects garbage to release any unreferenced memory. Example usage: obj.clear_model() - """ self.logger.info('Clearing the model from memory.') diff --git a/app_tools/ocr_analysis.py b/app_tools/ocr_analysis.py index 305853a..d6d23e9 100644 --- a/app_tools/ocr_analysis.py +++ b/app_tools/ocr_analysis.py @@ -10,7 +10,8 @@ class OCRProcessor: """ - This class represents an OCR Processor. It is responsible for performing OCR recognition on a list of images based on certain conditions defined in the code. + This class represents an OCR Processor. + It is responsible for performing OCR recognition on a list of images based on certain conditions defined in the code. Attributes: logger: Logger object for logging OCR analysis. @@ -30,8 +31,6 @@ def __init__(self, show_log: bool = True): Attributes: show_log (bool): A boolean value indicating whether to display log messages. Default is True. - logger: The logger object used for logging. - ocr_model: An instance of the ModifiedPaddleOCR class. """ self.logger = setup_logging('ocr_analysis') self.ocr_model = ModifiedPaddleOCR(show_log=show_log) @@ -42,22 +41,23 @@ def recognize_ocr(self, img_list: list, doc_layout_result: list) -> list: Parameters: - `img_list` (list): A list of images in numpy array format. - - `doc_layout_result` (list): A list containing the document layout results, each result representing a page in the document. Each page result should have a 'layout_dets' field. + - `doc_layout_result` (list): A list containing the document layout results, each result representing a page in the document. Returns: - `doc_layout_result` (list): The updated document layout result list with recognized text appended. - The method first converts each input image from the RGB color space to the BGR color space using OpenCV's `cv2.cvtColor` method. It then iterates over each image and its corresponding layout details in the document layout result. - - For each layout detail, the method checks if the category ID is either 13 or 14, which correspond to formula categories. If found, the bounding box coordinates of the layout detail are extracted and added to the `single_page_mfdetrec_res` list. - - Next, the method checks if the category ID is one of [0, 1, 2, 4, 6, 7], which represent categories that require OCR. If found, the bounding box coordinates are extracted, and a region of interest (ROI) is cropped from the image using the `pil_img.crop` method. This ROI image is then converted back to the BGR color space. - - The `self.ocr_model.ocr` method is then called, passing the cropped image along with the `single_page_mfdetrec_res` list, to perform OCR. The OCR result is obtained as a list of bounding boxes and their corresponding recognized text. - - If the OCR result is not empty, the method iterates over each bounding box and text pair in the result. The four corner points of the bounding box are extracted, along with the confidence score and the recognized text. A new layout detail is created with a category ID of 15 (corresponding to recognized text), and this detail is added to the `doc_layout_result`. - - Finally, the method logs the time taken for OCR recognition and returns the updated `doc_layout_result` list. + 1. Converts each input image from RGB color space to BGR color space using OpenCV's `cv2.cvtColor` method. + 2. Iterates over each image and its corresponding layout details in the document layout output. + 3. For each layout detail, checks whether the category ID is 13 or 14, which correspond to formula categories. + If found, the bounding box coordinates of the layout detail are extracted and added to the `single_page_mfdetrec_res` list. + 4. Checks whether the category ID is one of [0, 1, 2, 4, 6, 7], which represent categories that require OCR. + If found, the bounding box coordinates are extracted, and a region of interest (ROI) is cropped from the image using the `pil_img.crop` method. This ROI image is converted back to BGR color space. + 5. The `self.ocr_model.ocr` method is called, passing the cropped image along with the `single_page_mfdetrec_res` list, to perform the OCR. + The OCR result is returned as a list of bounding boxes and their corresponding recognized text. + 6. If the OCR result is not empty, the method iterates over each bounding box and text pair in the result. + The four corner points of the bounding box are extracted, along with the confidence score and the recognized text. + A new layout detail with a category ID of 15 (corresponding to the recognized text) is created, and this detail is added to the `doc_layout_result`. + 7. The time taken for the OCR recognition is recorded and the updated `doc_layout_result` list is returned. """ self.logger.debug('OCR recognition - init') start = time.time() @@ -79,7 +79,7 @@ def recognize_ocr(self, img_list: list, doc_layout_result: list) -> list: if int(res['category_id']) in [0, 1, 2, 4, 6, 7]: # Categories that need OCR xmin, ymin = int(res['poly'][0]), int(res['poly'][1]) xmax, ymax = int(res['poly'][4]), int(res['poly'][5]) - crop_box = [xmin, ymin, xmax, ymax] + crop_box = (xmin, ymin, xmax, ymax) cropped_img = Image.new('RGB', pil_img.size, 'white') cropped_img.paste(pil_img.crop(crop_box), crop_box) cropped_img = cv2.cvtColor(np.asarray(cropped_img), cv2.COLOR_RGB2BGR) diff --git a/app_tools/pdf.py b/app_tools/pdf.py index c43ef63..9143749 100644 --- a/app_tools/pdf.py +++ b/app_tools/pdf.py @@ -37,15 +37,6 @@ class PDFProcessor: Returns: dict: A dictionary containing the configuration settings. - setup_logging(name: str) -> Logger - Sets up the logging configuration. - - Parameters: - name (str): The name of the logger. - - Returns: - Logger: The logger object for logging messages. - Attributes: - config (dict): A dictionary containing the configuration settings. - dpi (int): The DPI (dots per inch) for the PDF files. diff --git a/app_tools/table_analysis.py b/app_tools/table_analysis.py index ce6dc73..b712034 100644 --- a/app_tools/table_analysis.py +++ b/app_tools/table_analysis.py @@ -10,20 +10,14 @@ class TableProcessor: """ This class represents a Table Processor that is used for table recognition in documents. The `TableProcessor` class has the following methods: - - - `__init__(self, config_path: str = None)`: Initializes a Table Processor object. It takes an optional `config_path` parameter which specifies the path to a configuration file. If no `config_path` is provided, the default configuration will be used. This method also initializes a logger and loads the configuration. It calls the `_init_tr_model` method to initialize the table recognition model. - - - `_init_tr_model(self)`: Initializes the table recognition model. It retrieves the model weight, maximum time, and device from the configuration. It then builds the model using the specified weight and maximum time. If the device is set to 'cuda', the model is moved to the GPU. The initialized table recognition model is returned. - - - `recognize_tables(self, img_list: list, doc_layout_result: list) -> list`: Performs table recognition on a list of images. It takes `img_list` as input, which is a list of images to process. It also takes `doc_layout_result`, which is a list containing layout results for each image. This method iterates over each image and its corresponding layout results. If a layout result has a 'category_id' of 5, indicating it is a table, the image is cropped and passed to the table recognition model. The output of the model is stored in the layout result as 'latex'. If the table recognition operation takes longer than the maximum time specified in the configuration, the layout result will have a 'timeout' flag set to True. The updated `doc_layout_result` is returned. - - - `clear_memory(self)`: Clears the table recognition model from memory. This method deletes the table recognition model, clears the GPU cache, and performs garbage collection to free up memory. - - Note: The code does not include the implementation of functions like `setup_logging`, `load_config`, `build_model`, and the import statements for the necessary libraries. """ def __init__(self, config_path: str = None): """ - This class initializes an instance of the software with the provided configuration path. + Initializes a Table Processor object. + It takes an optional `config_path` parameter which specifies the path to a configuration file. + If no `config_path` is provided, the default configuration will be used. + This method also initializes a logger and loads the configuration. + It calls the `_init_tr_model` method to initialize the table recognition model. Attributes: - logger: The logger instance for logging debug and error messages. @@ -44,11 +38,8 @@ def _init_tr_model(self): """ Initializes the translation model. - This method initializes the translation model by setting the weight, maximum time, and device attributes based on the provided configuration. It also builds the model using the `build_model` function. - - Parameters: - - self : object - The instance of the class that this method is called upon. + This method initializes the translation model by setting the weight, maximum time, and device attributes based on the provided configuration. + It also builds the model using the `build_model` function. Returns: - tr_model : object @@ -74,11 +65,15 @@ def recognize_tables(self, img_list: list, doc_layout_result: list) -> list: Returns: - A modified version of doc_layout_result with table recognition results added - The method initializes the table recognition process and sets the maximum time for the recognition. It then iterates through each image in img_list and retrieves the layout details for that image from doc_layout_result. + The method initializes the table recognition process and sets the maximum time for the recognition. + It then iterates through each image in img_list and retrieves the layout details for that image from doc_layout_result. - For each layout detail, if the category_id is 5 (indicating that it is a table), the method crops the image based on the polygon coordinates of the layout detail and performs table recognition on the cropped image. + For each layout detail, if the category_id is 5 (indicating that it is a table), + the method crops the image based on the polygon coordinates of the layout detail and performs table recognition on the cropped image. - The table recognition operation might take significant time, so a timeout check is performed to determine if the recognition process exceeds the maximum time. If it does, the timeout flag is set to True in the layout detail. + The table recognition operation might take significant time, + so a timeout check is performed to determine if the recognition process exceeds the maximum time. + If it does, the timeout flag is set to True in the layout detail. The recognized LaTeX output is assigned to the "latex" property of the layout detail. @@ -99,7 +94,7 @@ def recognize_tables(self, img_list: list, doc_layout_result: list) -> list: if int(res['category_id']) == 5: # Perform table recognition xmin, ymin = int(res['poly'][0]), int(res['poly'][1]) xmax, ymax = int(res['poly'][4]), int(res['poly'][5]) - crop_box = [xmin, ymin, xmax, ymax] + crop_box = (xmin, ymin, xmax, ymax) cropped_img = pil_img.crop(crop_box) start = time.time() @@ -120,13 +115,8 @@ def clear_memory(self): """ Clears the table recognition model from memory. - This method clears the table recognition model from memory by deleting the model object and releasing the memory occupied by the model. It also clears the CUDA cache and performs garbage collection. - - Parameters: - None - - Returns: - None + This method clears the table recognition model from memory by deleting the model object and releasing the memory occupied by the model. + It also clears the CUDA cache and performs garbage collection. Example: clear_memory() diff --git a/app_tools/utils.py b/app_tools/utils.py index eca5268..e93410e 100644 --- a/app_tools/utils.py +++ b/app_tools/utils.py @@ -4,12 +4,15 @@ def save_file(output_dir, single_pdf, doc_layout_result): """ - Save the document layout result as a JSON file in the specified output directory. + This function saves the document layout result as a JSON file in a specified output directory. - :param output_dir: The directory where the JSON file should be saved. - :param single_pdf: The path of the single PDF file. - :param doc_layout_result: The document layout result to be saved. - :return: The base name of the saved JSON file. + Parameters: + - output_dir (str): The directory where the JSON file will be saved. + - single_pdf (str): The path to the single PDF file. + - doc_layout_result (dict): The document layout result that will be saved as a JSON file. + + Returns: + - basename (str): The basename of the single PDF file. """ os.makedirs(output_dir, exist_ok=True) basename = os.path.basename(single_pdf)[0:-4] diff --git a/app_tools/visualize.py b/app_tools/visualize.py index eba0683..2428afc 100644 --- a/app_tools/visualize.py +++ b/app_tools/visualize.py @@ -20,13 +20,17 @@ "isolate_formula", "formula_caption", " ", " ", " ", "inline_formula", "isolated_formula", "ocr_text"] -def get_visualize(img_list: list, doc_layout_result, render: bool, output_dir, basename): +def get_visualize(img_list: list, doc_layout_result: list, render: bool, output_dir: str, basename: str): """ - This function takes a list of images, the result of a document layout analysis, a boolean flag 'render', an output directory path, and a basename as input arguments. It generates visualizations of the document layout and saves them as a PDF file. + This function takes a list of images, the result of a document layout analysis, a boolean flag 'render', an output directory path, and a basename as input arguments. + It generates visualizations of the document layout and saves them as a PDF file. Parameters: - img_list (list): A list of images. Each image should be a numpy array representing an image. - - doc_layout_result: The result of a document layout analysis. It should be a list of dictionaries, where each dictionary represents the layout details of a single page. Each dictionary should contain information such as the category ID, polygon coordinates, and text/latex content. + - doc_layout_result: The result of a document layout analysis. It should be a list of dictionaries, + where each dictionary represents the layout details of a single page. + Each dictionary should contain information such as the category ID, polygon coordinates, + and text/latex content. - render (bool): A boolean flag indicating whether to render the text/latex content in the visualizations. - output_dir: The output directory where the PDF file will be saved. - basename: The basename of the PDF file. @@ -43,14 +47,19 @@ def get_visualize(img_list: list, doc_layout_result, render: bool, output_dir, b get_visualize(img_list, doc_layout_result, render, output_dir, basename) """ vis_pdf_result = [] + for idx, image in enumerate(img_list): single_page_res = doc_layout_result[idx]['layout_dets'] - vis_img = Image.new('RGB', Image.fromarray(image).size, 'white') if render else Image.fromarray( - cv2.cvtColor(image, cv2.COLOR_RGB2BGR)) + + if render: + vis_img = Image.new('RGB', Image.fromarray(image).size, 'white') + else: + vis_img = Image.fromarray(cv2.cvtColor(image, cv2.COLOR_RGB2BGR)) draw = ImageDraw.Draw(vis_img) + for res in single_page_res: label = int(res['category_id']) - if label > 15: # categories that do not need visualize + if label > 15: # categories that do not need to visualize continue label_name = id2names[label] x_min, y_min = int(res['poly'][0]), int(res['poly'][1]) @@ -60,21 +69,23 @@ def get_visualize(img_list: list, doc_layout_result, render: bool, output_dir, b if label in [13, 14]: # render formula window_img = tex2pil(res['latex'])[0] else: - if True: # render chinese - window_img = zhtext2pil(res['text']) - else: # render english - window_img = tex2pil([res['text']], tex_type="text")[0] + window_img = zhtext2pil(res['text']) + # This code is unreachable + # if True: # render chinese + # window_img = zhtext2pil(res['text']) + # else: # render english + # window_img = tex2pil([res['text']], tex_type="text")[0] ratio = min((x_max - x_min) / window_img.width, (y_max - y_min) / window_img.height) - 0.05 window_img = window_img.resize( (int(window_img.width * ratio), int(window_img.height * ratio))) vis_img.paste(window_img, (int(x_min + (x_max - x_min - window_img.width) / 2), int(y_min + (y_max - y_min - window_img.height) / 2))) except Exception as e: - logger.error(f"got exception on {text}, error info: {e}") + logger.error(f"got exception on {res['text']}, error info: {e}") - draw.rectangle([x_min, y_min, x_max, y_max], fill=None, outline=color_palette[label], width=1) - fontText = ImageFont.truetype("assets/fonts/simhei.ttf", 15, encoding="utf-8") - draw.text((x_min, y_min), label_name, color_palette[label], font=fontText) + draw.rectangle((x_min, y_min, x_max, y_max), fill=None, outline=color_palette[label], width=1) + font_text = ImageFont.truetype("assets/fonts/simhei.ttf", 15, encoding="utf-8") + draw.text((x_min, y_min), label_name, color_palette[label], font=font_text) width, height = vis_img.size width, height = int(0.75 * width), int(0.75 * height) @@ -93,4 +104,4 @@ def get_visualize(img_list: list, doc_layout_result, render: bool, output_dir, b shutil.rmtree('./temp') except Exception as e: logger.error(f"got exception on shutil.rmtree, error info: {e}") - pass \ No newline at end of file + pass From 945d16bf56a87b3a71c13f0b2c6f8d5777635b9e Mon Sep 17 00:00:00 2001 From: angelgarcia Date: Wed, 4 Sep 2024 09:25:10 +0000 Subject: [PATCH 8/8] Add specific library versions to requirements.txt Updated library versions for consistency and reproducibility. Added new dependencies: torch, torchvision, numpy, opencv-python, Pillow, PyYAML, and pytz. --- requirements.txt | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/requirements.txt b/requirements.txt index b6a98a5..3593b7b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,15 @@ -unimernet -matplotlib -PyMuPDF -ultralytics -paddlepaddle-gpu +unimernet==0.1.6 +matplotlib==3.9.2 +PyMuPDF==1.24.9 +ultralytics==8.2.86 +paddlepaddle-gpu==2.6.1 paddleocr==2.7.3 -struct-eqtable==0.1.0 \ No newline at end of file +struct-eqtable==0.1.0 + +torch==2.3.1 +torchvision==0.18.1 +numpy==1.26.4 +opencv-python==4.6.0.66 +Pillow==8.4.0 +PyYAML==6.0.2 +pytz==2024.1 \ No newline at end of file