From fa21cff3a9b4defa860c4ab12d4f43935ee961f5 Mon Sep 17 00:00:00 2001 From: Robert Sachunsky Date: Thu, 30 Jul 2026 15:30:41 +0200 Subject: [PATCH] add extract-page CLI for cropping only --- src/eynollah/cli/__init__.py | 2 + src/eynollah/cli/cli_extract_page.py | 80 ++++++++++++++ src/eynollah/extract_page.py | 156 +++++++++++++++++++++++++++ 3 files changed, 238 insertions(+) create mode 100644 src/eynollah/cli/cli_extract_page.py create mode 100644 src/eynollah/extract_page.py diff --git a/src/eynollah/cli/__init__.py b/src/eynollah/cli/__init__.py index b75fb30..dd2835e 100644 --- a/src/eynollah/cli/__init__.py +++ b/src/eynollah/cli/__init__.py @@ -1,6 +1,7 @@ from .cli import main from .cli_binarize import binarize_cli from .cli_enhance import enhance_cli +from .cli_extract_page import extract_page_cli from .cli_extract_images import extract_images_cli from .cli_layout import layout_cli from .cli_models import models_cli @@ -13,4 +14,5 @@ main.add_command(layout_cli, 'layout') main.add_command(readingorder_cli, 'reorder') main.add_command(models_cli, 'models') main.add_command(ocr_cli, 'ocr') +main.add_command(extract_page_cli, 'extract-page') main.add_command(extract_images_cli, 'extract-images') diff --git a/src/eynollah/cli/cli_extract_page.py b/src/eynollah/cli/cli_extract_page.py new file mode 100644 index 0000000..8f555a5 --- /dev/null +++ b/src/eynollah/cli/cli_extract_page.py @@ -0,0 +1,80 @@ +import click + +@click.command(context_settings=dict( + help_option_names=['-h', '--help'], + show_default=True)) +@click.option( + "--image", + "-i", + help="input image filename", + type=click.Path(exists=True, dir_okay=False), +) + +@click.option( + "--out", + "-o", + help="directory for output PAGE-XML files", + type=click.Path(exists=True, file_okay=False), + required=True, +) +@click.option( + "--overwrite", + "-O", + help="overwrite (instead of skipping) if output xml exists", + is_flag=True, +) +@click.option( + "--dir_in", + "-di", + help="directory of input images (instead of --image)", + type=click.Path(exists=True, file_okay=False), +) +@click.option( + "--input_binary", + "-ib", + is_flag=True, + help="In general, eynollah uses RGB as input, but if the input document is very dark, very bright or for any other reason you can turn on internal binarization here. When set, eynollah will binarize the RGB input document first.", +) +@click.option( + "--num_col_upper", + "-ncu", + default=0, + type=click.IntRange(min=0), + help="lower limit of columns in document image; 0 means autodetected from model", +) +@click.option( + "--num_col_lower", + "-ncl", + default=0, + type=click.IntRange(min=0), + help="upper limit of columns in document image; 0 means autodetected from model", +) +@click.pass_context +def extract_page_cli( + ctx, + image, + out, + overwrite, + dir_in, + input_binary, + num_col_upper, + num_col_lower, +): + """ + Detect image regions only + """ + assert bool(image) != bool(dir_in), "Either -i (single input) or -di (directory) must be provided, but not both." + + from ..extract_page import EynollahPageExtractor + extractor = EynollahPageExtractor( + model_zoo=ctx.obj.model_zoo, + input_binary=input_binary, + num_col_upper=num_col_upper, + num_col_lower=num_col_lower, + ) + extractor.run(overwrite=overwrite, + image_filename=image, + dir_in=dir_in, + dir_out=out, + ) + diff --git a/src/eynollah/extract_page.py b/src/eynollah/extract_page.py new file mode 100644 index 0000000..7eb833f --- /dev/null +++ b/src/eynollah/extract_page.py @@ -0,0 +1,156 @@ +""" +extract page border (i.e. crop) +""" + +from concurrent.futures import ProcessPoolExecutor +import logging +from multiprocessing import cpu_count +import os +import time +from typing import Optional +from pathlib import Path +import numpy as np +import cv2 + +from eynollah.utils.contour import filter_contours_area_of_image, return_contours_of_image, return_contours_of_interested_region +from eynollah.utils.resize import resize_image + +from .model_zoo.model_zoo import EynollahModelZoo +from .writer import EynollahXmlWriter +from .eynollah import Eynollah +from .utils import box2rect, is_image_filename +from .plot import EynollahPlotter +from .utils import Region + +class EynollahPageExtractor(Eynollah): + + def __init__( + self, + *, + model_zoo: EynollahModelZoo, + enable_plotting : bool = False, + input_binary : bool = False, + ignore_page_extraction : bool = False, + num_col_upper : Optional[int] = None, + num_col_lower : Optional[int] = None, + full_layout : bool = False, + tables : bool = False, + curved_line : bool = False, + allow_enhancement : bool = False, + + ): + self.logger = logging.getLogger('eynollah.extract_page') + self.model_zoo = model_zoo + self.plotter = None + self.tables = tables + self.curved_line = curved_line + self.allow_enhancement = allow_enhancement + + self.enable_plotting = enable_plotting + # --input-binary sensible if image is very dark, if layout is not working. + self.input_binary = input_binary + self.full_layout = full_layout + self.ignore_page_extraction = ignore_page_extraction + if num_col_upper: + self.num_col_upper = int(num_col_upper) + else: + self.num_col_upper = num_col_upper + if num_col_lower: + self.num_col_lower = int(num_col_lower) + else: + self.num_col_lower = num_col_lower + + # for parallelization of CPU-intensive tasks: + self.executor = ProcessPoolExecutor(max_workers=cpu_count()) + + t_start = time.time() + + self.logger.info("Loading models...") + self.setup_models() + self.logger.info(f"Model initialization complete ({time.time() - t_start:.1f}s)") + + def setup_models(self, device=''): + + loadable = [ + "col_classifier", + "page", + ] + if self.input_binary: + loadable.append("binarization") + self.model_zoo.load_models(*loadable, device=device) + + def run(self, + overwrite: bool = False, + image_filename: Optional[str] = None, + dir_in: Optional[str] = None, + dir_out: Optional[str] = None, + **kwargs + ): + """ + Get scanned image and scales, then detect the page border + """ + self.logger.debug("enter run") + if dir_in: + t0_tot = time.time() + ls_imgs = [os.path.join(dir_in, image_filename) + for image_filename in filter(is_image_filename, + os.listdir(dir_in))] + elif image_filename: + ls_imgs = [image_filename] + else: + raise ValueError("run requires either a single image filename or a directory") + + for img_filename in ls_imgs: + self.run_single(img_filename, dir_out=dir_out, overwrite=overwrite) + + if dir_in: + self.logger.info("All jobs done in %.1fs", time.time() - t0_tot) + + def run_single(self, + img_filename: str, + dir_out: Optional[str] = None, + overwrite: bool = False + ) -> None: + t0 = time.time() + self.logger.info(img_filename) + + image = self.cache_images(image_filename=img_filename) + writer = EynollahXmlWriter( + dir_out=dir_out, + image_filename=img_filename, + image_width=image['img'].shape[1], + image_height=image['img'].shape[0], + ) + + if os.path.exists(writer.output_filename): + if overwrite: + self.logger.warning("will overwrite existing output file '%s'", writer.output_filename) + else: + self.logger.warning("will skip input for existing output file '%s'", writer.output_filename) + return + + self.logger.info(f"Processing file: {writer.image_filename}") + self.logger.info("Step 1/5: Image Enhancement") + + num_col_classifier, _ = self.run_enhancement(image) + writer.scale_x = image['scale_x'] + writer.scale_y = image['scale_y'] + + self.logger.info(f"Image: {image['img_res'].shape[1]}x{image['img_res'].shape[0]}, " + f"scale {image['scale_x']:.1f}x{image['scale_y']:.1f}, " + f"{image['dpi']} DPI, {num_col_classifier} columns") + self.logger.info(f"Enhancement complete ({time.time() - t0:.1f}s)") + + # Image Extraction Mode + self.logger.info("Step 2/5: Image Extraction Mode") + t1 = time.time() + page_cont, _, _ = self.extract_page(image) + page = Region(page_cont) + + pcgts = writer.build_pagexml( + page=page, + img_bin=self.imread(image, binary=True) if self.input_binary else None, + num_col=num_col_classifier, + ) + writer.write_pagexml(pcgts) + self.logger.info("Job done in %.1fs", time.time() - t0)