Compare commits

...

70 commits

Author SHA1 Message Date
vahidrezanezhad
5725e4fd1f -Continue processing when num_col is None but textregions exist. -Convert marginal-only to main body if no main body is present. -Reset deskew angle to 0 when text region density (textregion area to page area) < 0.3 and angle > 45°. 2025-10-01 15:58:03 +02:00
Konstantin Baierer
a6f0af07d1
Merge pull request #185 from bertsky/patch-4
CD: master is now main
2025-09-29 10:44:27 +02:00
Robert Sachunsky
92c1e824dc
CD: master is now main 2025-09-26 23:05:47 +02:00
kba
6ea6a62801 📝 v0.5.0 2025-09-26 16:23:46 +02:00
Konstantin Baierer
882e242946
Merge pull request #178 from qurator-spk/prepare-release-v0.5.0
Prepare release v0.5.0
2025-09-26 16:21:09 +02:00
kba
37e64b4e45 📝 changelog 2025-09-26 16:19:04 +02:00
kba
3123add815 📝 update README 2025-09-26 15:07:32 +02:00
kba
830cc2c30a comment out the offending test outright 2025-09-26 14:37:04 +02:00
kba
eb8d4573a8 tests: also disable ...ocr_directory test 2025-09-26 13:57:08 +02:00
kba
42fb452a7e disable the -doit OCR test 2025-09-26 12:55:29 +02:00
Robert Sachunsky
480daa4c7c test_run: make ocr -doit work (add truetype file) 2025-09-25 22:28:15 +02:00
kba
4c6405713a ci: ocr models 2025-09-25 22:19:36 +02:00
kba
b4d460ca79 makefile forgot the OCR models 2025-09-25 22:16:38 +02:00
kba
f3f5426597 Merge branch 'adapt-ocrd' of https://github.com/qurator-spk/eynollah into adapt-ocrd 2025-09-25 21:47:27 +02:00
kba
0bb1fb1a05 tests: adapt to layout/ocr model split 2025-09-25 21:47:15 +02:00
kba
2ec773128b Merge branch 'adapt-ocrd' of https://github.com/qurator-spk/eynollah into adapt-ocrd 2025-09-25 21:40:48 +02:00
kba
f37d80c188 Merge branch 'adapt-ocrd' of https://github.com/qurator-spk/eynollah into adapt-ocrd 2025-09-25 21:39:55 +02:00
kba
57ee1cdc72 Merge remote-tracking branch 'bertsky/mbro_dead_code-plus-fixes-plus-tests' into adapt-ocrd 2025-09-25 21:39:36 +02:00
kba
5c0ab509c4 CI: Update model name 2025-09-25 21:17:32 +02:00
kba
9303ded11f ocrd-tool.json: use models_layout instead of eynollah_layouts for consistency 2025-09-25 21:12:52 +02:00
Robert Sachunsky
7c79902835 enhancement/mbreorder: make all path options kwargs to run() instead of attributes 2025-09-25 20:51:02 +02:00
kba
e6ee26fde3 make models: adapt to zenodo/v0.5.0 2025-09-25 20:35:54 +02:00
kba
11de8a025d Adapt ocrd-eynollah-segment for release 2025-09-25 20:11:48 +02:00
kba
5e15c4f248 Merge remote-tracking branch 'bertsky/mbro_dead_code-plus-fixes-plus-tests' into prepare-release-v0.5.0 2025-09-25 20:05:03 +02:00
Robert Sachunsky
5c7e1f21fb test_run: add tests for ocr 2025-09-25 19:53:19 +02:00
Robert Sachunsky
2d14d57e4f ocr: minimal debug logging 2025-09-25 19:52:50 +02:00
Robert Sachunsky
1dcc7b5795 ocr CLI: make --model vs --model_name xor 2025-09-25 16:38:43 +02:00
Robert Sachunsky
5b1e0c1327 layout/ocr: make all path options kwargs to run() instead of attributes; ocr: drop redundant prediction_with_both_of_rgb_and_bin in favour of just bool(dir_in_bin) 2025-09-25 16:26:31 +02:00
Robert Sachunsky
ef1304a764 CLIs: reorder options, explain -i vs -di 2025-09-25 16:11:39 +02:00
Robert Sachunsky
df5448cdcd CLIs: add required=True where missing 2025-09-25 16:08:40 +02:00
Robert Sachunsky
58dd192fad smoke-test: also add enhancement and mbreorder here 2025-09-25 16:05:45 +02:00
b-vr103
369ef573f9 get textlines sorted in textregions - detection of vertical and horizontal regions improved 2025-09-25 12:51:02 +02:00
Robert Sachunsky
f07df080f0 add tests for enhancement and mbreorder 2025-09-25 01:16:19 +02:00
Robert Sachunsky
9967510327 mbreorder: filter by .xml suffix in dir-in mode 2025-09-25 01:15:37 +02:00
Robert Sachunsky
b094a6b77f mbreorder: avoid spaces in logger name 2025-09-25 01:15:37 +02:00
Robert Sachunsky
d6cdb69acb binarize/enhance/layout/ocr ls_imgs: use the same file name suffix filter for dir-in mode 2025-09-25 01:15:37 +02:00
Robert Sachunsky
96a0d22496 mbreorder CLI: change options to mimic other commands 2025-09-25 01:15:37 +02:00
Robert Sachunsky
93f7588bfa binarizer CLI: add --log-level 2025-09-24 23:08:50 +02:00
Robert Sachunsky
8a1e5a8950 enhancement / layout CLI: do not override logger name 2025-09-24 23:03:11 +02:00
Robert Sachunsky
960b11f51f machine-based-reading-order CLI: no foreign logger, add --log-level 2025-09-24 22:58:57 +02:00
kba
45b05c2316 Merge branch 'mbro_dead_code' into prepare-release-v0.5.0 2025-09-24 17:18:31 +02:00
vahidrezanezhad
80d50d4bf6 get textlines sorted in textregion - verticals 2025-09-24 17:17:27 +02:00
b-vr103
6d8641a518 get textlines sorted in textregion - verticals 2025-09-24 17:17:21 +02:00
vahidrezanezhad
6904a98182 get textlines inside textregion sorted debugging 2025-09-24 17:17:12 +02:00
vahidrezanezhad
ce13d8c5a3 get textlines inside textregion sorted 2025-09-24 17:16:47 +02:00
kba
8b30bdbae2 image_enhancer: use latest page extraction model 2025-09-24 16:39:31 +02:00
kba
c8ebe84697 image_enhancer: add missing models, remove dead code 2025-09-24 16:36:18 +02:00
kba
b75ca0d31f mb_ro_on_layout: remove copy-pasta code not actually used 2025-09-24 16:29:05 +02:00
Konstantin Baierer
9c129c7f54
Merge pull request #180 from bertsky/prepare-release-v0.5.0-fixlogging
prepare release v0.5.0: fix logging
2025-09-24 12:28:10 +02:00
Robert Sachunsky
5bd318e657 rm print statement (already log msg) 2025-09-24 12:14:32 +02:00
Robert Sachunsky
90f1d7aa47 rm summary msg (info already logged elsewhere) 2025-09-24 12:10:11 +02:00
Robert Sachunsky
7933b103f5 log modes only once (in run, not in run_single) 2025-09-24 12:09:30 +02:00
Robert Sachunsky
d0817f5744 fix typo 2025-09-24 12:08:50 +02:00
kba
9ead58b99a Merge remote-tracking branch 'michalbubula/add-feedback' into prepare-release-v0.5.0 2025-09-23 19:50:27 +02:00
kba
7bde99e866 Merge remote-tracking branch 'origin/updating_readme_for_eynollah_use_cases' into prepare-release-v0.5.0 2025-09-23 19:42:55 +02:00
kba
df8d93dbfa Merge branch 'main' into add-feedback 2025-09-23 19:20:20 +02:00
kba
5c9cf8472b remove redundant/brittle interval logging 2025-09-18 13:19:57 +02:00
kba
146102842a convert all print stmts to logger.info calls 2025-09-18 13:15:18 +02:00
kba
c64d102613 move logging to CLI and make initialization optional 2025-09-18 13:07:41 +02:00
vahidrezanezhad
6a735daa60
Update README.md 2025-08-31 23:30:54 +02:00
michalbubula
8ebba5ac04 add feedback to command line interface 2025-08-12 16:21:15 +02:00
Clemens Neudecker
2996fc8b30
Merge pull request #166 from qurator-spk/updating_readme_for_eynollah_use_cases-cli
Updating readme for eynollah use cases cli
2025-07-24 15:30:57 +02:00
vahidrezanezhad
fd0595f920
Update Makefile 2025-07-24 13:52:38 +02:00
vahidrezanezhad
da141bb42e resolving tests error 2025-07-23 16:44:17 +02:00
kba
32889ef1e0 adapt binarization CLI according to #156 2025-06-12 13:57:41 +02:00
vahidrezanezhad
9b4e78c55c
Fixed duplicate textline_light assignments (true and false) in the OCR-D framework for the Eynollah light version, which caused rectangles to be used instead of contours for textlines 2025-06-11 18:57:08 +02:00
cneud
7a22e51f5d resolve some comments from review 2025-05-14 21:56:03 +02:00
vahidrezanezhad
21ec4fbfb5 The text region coordinates are now correctly written into the XML output when using the skip layout and reading order option 2025-05-07 14:04:01 +02:00
vahidrezanezhad
83211ae684 In the case of skip_layout_and_reading_order, the confidence value was not set correctly, leading to an error while writing to the XML file. 2025-05-07 12:33:03 +02:00
vahidrezanezhad
192b9111e3 updating eynollah README, how to use it for use cases 2025-04-22 00:23:01 +02:00
22 changed files with 4756 additions and 748 deletions

View file

@ -2,7 +2,7 @@ name: CD
on:
push:
branches: [ "master" ]
branches: [ "main" ]
workflow_dispatch: # run manually
jobs:

View file

@ -27,7 +27,12 @@ jobs:
- uses: actions/cache@v4
id: seg_model_cache
with:
path: models_eynollah
path: models_layout_v0_5_0
key: ${{ runner.os }}-models
- uses: actions/cache@v4
id: ocr_model_cache
with:
path: models_ocr_v0_5_0
key: ${{ runner.os }}-models
- uses: actions/cache@v4
id: bin_model_cache
@ -35,7 +40,7 @@ jobs:
path: default-2021-03-09
key: ${{ runner.os }}-modelbin
- name: Download models
if: steps.seg_model_cache.outputs.cache-hit != 'true' || steps.bin_model_cache.outputs.cache-hit != 'true'
if: steps.seg_model_cache.outputs.cache-hit != 'true' || steps.bin_model_cache.outputs.cache-hit != 'true' || steps.ocr_model_cache.outputs.cache-hit != true
run: make models
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5

1
.gitignore vendored
View file

@ -5,3 +5,4 @@ models_eynollah*
output.html
/build
/dist
*.tif

View file

@ -5,9 +5,17 @@ Versioned according to [Semantic Versioning](http://semver.org/).
## Unreleased
## [0.5.0] - 2025-09-26
Fixed:
* restoring the contour in the original image caused an error due to an empty tuple
* restoring the contour in the original image caused an error due to an empty tuple, #154
Added:
* `eynollah machine-based-reading-order` CLI to run reading order detection, #175
* `eynollah enhancement` CLI to run image enhancement, #175
* Improved models for page extraction and reading order detection, #175
## [0.4.0] - 2025-04-07
@ -187,6 +195,8 @@ Fixed:
Initial release
<!-- link-labels -->
[0.5.0]: ../../compare/v0.5.0...v0.4.0
[0.4.0]: ../../compare/v0.4.0...v0.3.1
[0.3.1]: ../../compare/v0.3.1...v0.3.0
[0.3.0]: ../../compare/v0.3.0...v0.2.0
[0.2.0]: ../../compare/v0.2.0...v0.1.0

View file

@ -9,12 +9,15 @@ DOCKER ?= docker
#SEG_MODEL := https://qurator-data.de/eynollah/2021-04-25/models_eynollah.tar.gz
#SEG_MODEL := https://qurator-data.de/eynollah/2022-04-05/models_eynollah_renamed.tar.gz
SEG_MODEL := https://qurator-data.de/eynollah/2022-04-05/models_eynollah.tar.gz
# SEG_MODEL := https://qurator-data.de/eynollah/2022-04-05/models_eynollah.tar.gz
#SEG_MODEL := https://github.com/qurator-spk/eynollah/releases/download/v0.3.0/models_eynollah.tar.gz
#SEG_MODEL := https://github.com/qurator-spk/eynollah/releases/download/v0.3.1/models_eynollah.tar.gz
SEG_MODEL := https://zenodo.org/records/17194824/files/models_layout_v0_5_0.tar.gz?download=1
BIN_MODEL := https://github.com/qurator-spk/sbb_binarization/releases/download/v0.0.11/saved_model_2021_03_09.zip
OCR_MODEL := https://zenodo.org/records/17194824/files/models_ocr_v0_5_0.tar.gz?download=1
PYTEST_ARGS ?= -vv
# BEGIN-EVAL makefile-parser --make-help Makefile
@ -28,7 +31,7 @@ help:
@echo " install Install package with pip"
@echo " install-dev Install editable with pip"
@echo " deps-test Install test dependencies with pip"
@echo " models Download and extract models to $(CURDIR)/models_eynollah"
@echo " models Download and extract models to $(CURDIR)/models_layout_v0_5_0"
@echo " smoke-test Run simple CLI check"
@echo " ocrd-test Run OCR-D CLI check"
@echo " test Run unit tests"
@ -44,14 +47,20 @@ help:
# END-EVAL
# Download and extract models to $(PWD)/models_eynollah
models: models_eynollah default-2021-03-09
# Download and extract models to $(PWD)/models_layout_v0_5_0
models: models_layout_v0_5_0 models_ocr_v0_5_0 default-2021-03-09
models_eynollah: models_eynollah.tar.gz
tar zxf models_eynollah.tar.gz
models_layout_v0_5_0: models_layout_v0_5_0.tar.gz
tar zxf models_layout_v0_5_0.tar.gz
models_eynollah.tar.gz:
wget $(SEG_MODEL)
models_layout_v0_5_0.tar.gz:
wget -O $@ $(SEG_MODEL)
models_ocr_v0_5_0: models_ocr_v0_5_0.tar.gz
tar zxf models_ocr_v0_5_0.tar.gz
models_ocr_v0_5_0.tar.gz:
wget -O $@ $(OCR_MODEL)
default-2021-03-09: $(notdir $(BIN_MODEL))
unzip $(notdir $(BIN_MODEL))
@ -73,20 +82,28 @@ install:
install-dev:
$(PIP) install -e .$(and $(EXTRAS),[$(EXTRAS)])
deps-test: models_eynollah
deps-test: models_layout_v0_5_0
$(PIP) install -r requirements-test.txt
smoke-test: TMPDIR != mktemp -d
smoke-test: tests/resources/kant_aufklaerung_1784_0020.tif
# layout analysis:
eynollah layout -i $< -o $(TMPDIR) -m $(CURDIR)/models_eynollah
eynollah layout -i $< -o $(TMPDIR) -m $(CURDIR)/models_layout_v0_5_0
fgrep -q http://schema.primaresearch.org/PAGE/gts/pagecontent/2019-07-15 $(TMPDIR)/$(basename $(<F)).xml
fgrep -c -e TextRegion -e ImageRegion -e SeparatorRegion $(TMPDIR)/$(basename $(<F)).xml
# directory mode (skip one, add one):
eynollah layout -di $(<D) -o $(TMPDIR) -m $(CURDIR)/models_eynollah
# layout, directory mode (skip one, add one):
eynollah layout -di $(<D) -o $(TMPDIR) -m $(CURDIR)/models_layout_v0_5_0
test -s $(TMPDIR)/euler_rechenkunst01_1738_0025.xml
# mbreorder, directory mode (overwrite):
eynollah machine-based-reading-order -di $(<D) -o $(TMPDIR) -m $(CURDIR)/models_layout_v0_5_0
fgrep -q http://schema.primaresearch.org/PAGE/gts/pagecontent/2019-07-15 $(TMPDIR)/$(basename $(<F)).xml
fgrep -c -e RegionRefIndexed $(TMPDIR)/$(basename $(<F)).xml
# binarize:
eynollah binarization -m $(CURDIR)/default-2021-03-09 $< $(TMPDIR)/$(<F)
eynollah binarization -m $(CURDIR)/default-2021-03-09 -i $< -o $(TMPDIR)/$(<F)
test -s $(TMPDIR)/$(<F)
@set -x; test "$$(identify -format '%w %h' $<)" = "$$(identify -format '%w %h' $(TMPDIR)/$(<F))"
# enhance:
eynollah enhancement -m $(CURDIR)/models_layout_v0_5_0 -sos -i $< -o $(TMPDIR) -O
test -s $(TMPDIR)/$(<F)
@set -x; test "$$(identify -format '%w %h' $<)" = "$$(identify -format '%w %h' $(TMPDIR)/$(<F))"
$(RM) -r $(TMPDIR)
@ -97,7 +114,7 @@ ocrd-test: tests/resources/kant_aufklaerung_1784_0020.tif
cp $< $(TMPDIR)
ocrd workspace -d $(TMPDIR) init
ocrd workspace -d $(TMPDIR) add -G OCR-D-IMG -g PHYS_0020 -i OCR-D-IMG_0020 $(<F)
ocrd-eynollah-segment -w $(TMPDIR) -I OCR-D-IMG -O OCR-D-SEG -P models $(CURDIR)/models_eynollah
ocrd-eynollah-segment -w $(TMPDIR) -I OCR-D-IMG -O OCR-D-SEG -P models $(CURDIR)/models_layout_v0_5_0
result=$$(ocrd workspace -d $(TMPDIR) find -G OCR-D-SEG); \
fgrep -q http://schema.primaresearch.org/PAGE/gts/pagecontent/2019-07-15 $(TMPDIR)/$$result && \
fgrep -c -e TextRegion -e ImageRegion -e SeparatorRegion $(TMPDIR)/$$result
@ -106,8 +123,9 @@ ocrd-test: tests/resources/kant_aufklaerung_1784_0020.tif
$(RM) -r $(TMPDIR)
# Run unit tests
test: export EYNOLLAH_MODELS=$(CURDIR)/models_eynollah
test: export SBBBIN_MODELS=$(CURDIR)/default-2021-03-09
test: export MODELS_LAYOUT=$(CURDIR)/models_layout_v0_5_0
test: export MODELS_OCR=$(CURDIR)/models_ocr_v0_5_0
test: export MODELS_BIN=$(CURDIR)/default-2021-03-09
test:
$(PYTHON) -m pytest tests --durations=0 --continue-on-collection-errors $(PYTEST_ARGS)

View file

@ -1,5 +1,6 @@
# Eynollah
> Document Layout Analysis with Deep Learning and Heuristics
> Document Layout Analysis, Binarization and OCR with Deep Learning and Heuristics
[![PyPI Version](https://img.shields.io/pypi/v/eynollah)](https://pypi.org/project/eynollah/)
[![GH Actions Test](https://github.com/qurator-spk/eynollah/actions/workflows/test-eynollah.yml/badge.svg)](https://github.com/qurator-spk/eynollah/actions/workflows/test-eynollah.yml)
@ -19,9 +20,11 @@
* Output in [PAGE-XML](https://github.com/PRImA-Research-Lab/PAGE-XML)
* [OCR-D](https://github.com/qurator-spk/eynollah#use-as-ocr-d-processor) interface
:warning: Development is currently focused on achieving the best possible quality of results for a wide variety of historical documents and therefore processing can be very slow. We aim to improve this, but contributions are welcome.
:warning: Development is currently focused on achieving the best possible quality of results for a wide variety of
historical documents and therefore processing can be very slow. We aim to improve this, but contributions are welcome.
## Installation
Python `3.8-3.11` with Tensorflow `<2.13` on Linux are currently supported.
For (limited) GPU support the CUDA toolkit needs to be installed.
@ -41,19 +44,40 @@ cd eynollah; pip install -e .
Alternatively, you can run `make install` or `make install-dev` for editable installation.
To also install the dependencies for the OCR engines:
```
pip install "eynollah[OCR]"
# or
make install EXTRAS=OCR
```
## Models
Pre-trained models can be downloaded from [qurator-data.de](https://qurator-data.de/eynollah/) or [huggingface](https://huggingface.co/SBB?search_models=eynollah).
Pretrained models can be downloaded from [zenodo](https://zenodo.org/records/17194824) or [huggingface](https://huggingface.co/SBB?search_models=eynollah).
For documentation on methods and models, have a look at [`models.md`](https://github.com/qurator-spk/eynollah/tree/main/docs/models.md).
## Train
In case you want to train your own model with Eynollah, have a look at [`train.md`](https://github.com/qurator-spk/eynollah/tree/main/docs/train.md).
## Usage
The command-line interface can be called like this:
Eynollah supports five use cases: layout analysis (segmentation), binarization,
image enhancement, text recognition (OCR), and (trainable) reading order detection.
### Layout Analysis
The layout analysis module is responsible for detecting layouts, identifying text lines, and determining reading order
using both heuristic methods or a machine-based reading order detection model.
Note that there are currently two supported ways for reading order detection: either as part of layout analysis based
on image input, or, currently under development, for given layout analysis results based on PAGE-XML data as input.
The command-line interface for layout analysis can be called like this:
```sh
eynollah \
eynollah layout \
-i <single image file> | -di <directory containing image files> \
-o <output directory> \
-m <directory containing model files> \
@ -66,6 +90,7 @@ The following options can be used to further configure the processing:
|-------------------|:-------------------------------------------------------------------------------|
| `-fl` | full layout analysis including all steps and segmentation classes |
| `-light` | lighter and faster but simpler method for main region detection and deskewing |
| `-tll` | this indicates the light textline and should be passed with light version |
| `-tab` | apply table detection |
| `-ae` | apply enhancement (the resulting image is saved to the output directory) |
| `-as` | apply scaling |
@ -80,9 +105,51 @@ The following options can be used to further configure the processing:
| `-sp <directory>` | save cropped page image to this directory |
| `-sa <directory>` | save all (plot, enhanced/binary image, layout) to this directory |
If no option is set, the tool performs layout detection of main regions (background, text, images, separators and marginals).
If no option is set, the tool performs layout detection of main regions (background, text, images, separators
and marginals).
The best output quality is produced when RGB images are used as input rather than greyscale or binarized images.
### Binarization
The binarization module performs document image binarization using pretrained pixelwise segmentation models.
The command-line interface for binarization of single image can be called like this:
```sh
eynollah binarization \
-i <single image file> | -di <directory containing image files> \
-o <output directory> \
-m <directory containing model files> \
```
### OCR
The OCR module performs text recognition from images using two main families of pretrained models: CNN-RNNbased OCR and Transformer-based OCR.
The command-line interface for ocr can be called like this:
```sh
eynollah ocr \
-i <single image file> | -di <directory containing image files> \
-dx <directory of xmls> \
-o <output directory> \
-m <path to directory containing model files> | --model_name <path to specific model> \
```
### Machine-based-reading-order
The machine-based reading-order module employs a pretrained model to identify the reading order from layouts represented in PAGE-XML files.
The command-line interface for machine based reading order can be called like this:
```sh
eynollah machine-based-reading-order \
-i <single image file> | -di <directory containing image files> \
-xml <xml file name> | -dx <directory containing xml files> \
-m <path to directory containing model files> \
-o <output directory>
```
#### Use as OCR-D processor
Eynollah ships with a CLI interface to be used as [OCR-D](https://ocr-d.de) [processor](https://ocr-d.de/en/spec/cli),
@ -90,8 +157,7 @@ formally described in [`ocrd-tool.json`](https://github.com/qurator-spk/eynollah
In this case, the source image file group with (preferably) RGB images should be used as input like this:
ocrd-eynollah-segment -I OCR-D-IMG -O OCR-D-SEG -P models 2022-04-05
ocrd-eynollah-segment -I OCR-D-IMG -O OCR-D-SEG -P models eynollah_layout_v0_5_0
If the input file group is PAGE-XML (from a previous OCR-D workflow step), Eynollah behaves as follows:
- existing regions are kept and ignored (i.e. in effect they might overlap segments from Eynollah results)
@ -103,15 +169,20 @@ If the input file group is PAGE-XML (from a previous OCR-D workflow step), Eynol
(because some other preprocessing step was in effect like `denoised`), then
the output PAGE-XML will be based on that as new top-level (`@imageFilename`)
ocrd-eynollah-segment -I OCR-D-XYZ -O OCR-D-SEG -P models 2022-04-05
ocrd-eynollah-segment -I OCR-D-XYZ -O OCR-D-SEG -P models eynollah_layout_v0_5_0
Still, in general, it makes more sense to add other workflow steps **after** Eynollah.
There is also an OCR-D processor for the binarization:
ocrd-sbb-binarize -I OCR-D-IMG -O OCR-D-BIN -P models default-2021-03-09
#### Additional documentation
Please check the [wiki](https://github.com/qurator-spk/eynollah/wiki).
## How to cite
If you find this tool useful in your work, please consider citing our paper:
```bibtex

View file

@ -1,5 +1,6 @@
# Models documentation
This suite of 14 models presents a document layout analysis (DLA) system for historical documents implemented by
This suite of 15 models presents a document layout analysis (DLA) system for historical documents implemented by
pixel-wise segmentation using a combination of a ResNet50 encoder with various U-Net decoders. In addition, heuristic
methods are applied to detect marginals and to determine the reading order of text regions.
@ -23,6 +24,7 @@ See the flowchart below for the different stages and how they interact:
## Models
### Image enhancement
Model card: [Image Enhancement](https://huggingface.co/SBB/eynollah-enhancement)
This model addresses image resolution, specifically targeting documents with suboptimal resolution. In instances where
@ -30,12 +32,14 @@ the detection of document layout exhibits inadequate performance, the proposed e
the quality and clarity of the images, thus facilitating enhanced visual interpretation and analysis.
### Page extraction / border detection
Model card: [Page Extraction/Border Detection](https://huggingface.co/SBB/eynollah-page-extraction)
A problem that can negatively affect OCR are black margins around a page caused by document scanning. A deep learning
model helps to crop to the page borders by using a pixel-wise segmentation method.
### Column classification
Model card: [Column Classification](https://huggingface.co/SBB/eynollah-column-classifier)
This model is a trained classifier that recognizes the number of columns in a document by use of a training set with
@ -43,6 +47,7 @@ manual classification of all documents into six classes with either one, two, th
respectively.
### Binarization
Model card: [Binarization](https://huggingface.co/SBB/eynollah-binarization)
This model is designed to tackle the intricate task of document image binarization, which involves segmentation of the
@ -52,6 +57,7 @@ capability of the model enables improved accuracy and reliability in subsequent
enhanced document understanding and interpretation.
### Main region detection
Model card: [Main Region Detection](https://huggingface.co/SBB/eynollah-main-regions)
This model has employed a different set of labels, including an artificial class specifically designed to encompass the
@ -61,6 +67,7 @@ during the inference phase. By incorporating this methodology, improved efficien
model's ability to accurately identify and classify text regions within documents.
### Main region detection (with scaling augmentation)
Model card: [Main Region Detection (with scaling augmentation)](https://huggingface.co/SBB/eynollah-main-regions-aug-scaling)
Utilizing scaling augmentation, this model leverages the capability to effectively segment elements of extremely high or
@ -69,12 +76,14 @@ categorizing and isolating such elements, thereby enhancing its overall performa
documents with varying scale characteristics.
### Main region detection (with rotation augmentation)
Model card: [Main Region Detection (with rotation augmentation)](https://huggingface.co/SBB/eynollah-main-regions-aug-rotation)
This model takes advantage of rotation augmentation. This helps the tool to segment the vertical text regions in a
robust way.
### Main region detection (ensembled)
Model card: [Main Region Detection (ensembled)](https://huggingface.co/SBB/eynollah-main-regions-ensembled)
The robustness of this model is attained through an ensembling technique that combines the weights from various epochs.
@ -82,16 +91,19 @@ By employing this approach, the model achieves a high level of resilience and st
strengths of multiple epochs to enhance its overall performance and deliver consistent and reliable results.
### Full region detection (1,2-column documents)
Model card: [Full Region Detection (1,2-column documents)](https://huggingface.co/SBB/eynollah-full-regions-1column)
This model deals with documents comprising of one and two columns.
### Full region detection (3,n-column documents)
Model card: [Full Region Detection (3,n-column documents)](https://huggingface.co/SBB/eynollah-full-regions-3pluscolumn)
This model is responsible for detecting headers and drop capitals in documents with three or more columns.
### Textline detection
Model card: [Textline Detection](https://huggingface.co/SBB/eynollah-textline)
The method for textline detection combines deep learning and heuristics. In the deep learning part, an image-to-image
@ -106,6 +118,7 @@ segmentation is first deskewed and then the textlines are separated with the sam
textline bounding boxes. Later, the strap is rotated back into its original orientation.
### Textline detection (light)
Model card: [Textline Detection Light (simpler but faster method)](https://huggingface.co/SBB/eynollah-textline_light)
The method for textline detection combines deep learning and heuristics. In the deep learning part, an image-to-image
@ -119,6 +132,7 @@ enhancing the model's ability to accurately identify and delineate individual te
eliminates the need for additional heuristics in extracting textline contours.
### Table detection
Model card: [Table Detection](https://huggingface.co/SBB/eynollah-tables)
The objective of this model is to perform table segmentation in historical document images. Due to the pixel-wise
@ -128,17 +142,21 @@ effectively identify and delineate tables within the historical document images,
enabling subsequent analysis and interpretation.
### Image detection
Model card: [Image Detection](https://huggingface.co/SBB/eynollah-image-extraction)
This model is used for the task of illustration detection only.
### Reading order detection
Model card: [Reading Order Detection]()
TODO
## Heuristic methods
Additionally, some heuristic methods are employed to further improve the model predictions:
* After border detection, the largest contour is determined by a bounding box, and the image cropped to these coordinates.
* For text region detection, the image is scaled up to make it easier for the model to detect background space between text regions.
* A minimum area is defined for text regions in relation to the overall image dimensions, so that very small regions that are noise can be filtered out.

View file

@ -1,4 +1,5 @@
# Training documentation
This aims to assist users in preparing training datasets, training models, and performing inference with trained models.
We cover various use cases including pixel-wise segmentation, image classification, image enhancement, and machine-based
reading order detection. For each use case, we provide guidance on how to generate the corresponding training dataset.
@ -11,6 +12,7 @@ The following three tasks can all be accomplished using the code in the
* inference with the trained model
## Generate training dataset
The script `generate_gt_for_training.py` is used for generating training datasets. As the results of the following
command demonstrates, the dataset generator provides three different commands:
@ -23,14 +25,19 @@ These three commands are:
* pagexml2label
### image-enhancement
Generating a training dataset for image enhancement is quite straightforward. All that is needed is a set of
high-resolution images. The training dataset can then be generated using the following command:
`python generate_gt_for_training.py image-enhancement -dis "dir of high resolution images" -dois "dir where degraded
images will be written" -dols "dir where the corresponding high resolution image will be written as label" -scs
"degrading scales json file"`
```sh
python generate_gt_for_training.py image-enhancement \
-dis "dir of high resolution images" \
-dois "dir where degraded images will be written" \
-dols "dir where the corresponding high resolution image will be written as label" \
-scs "degrading scales json file"
```
The scales JSON file is a dictionary with a key named 'scales' and values representing scales smaller than 1. Images are
The scales JSON file is a dictionary with a key named `scales` and values representing scales smaller than 1. Images are
downscaled based on these scales and then upscaled again to their original size. This process causes the images to lose
resolution at different scales. The degraded images are used as input images, and the original high-resolution images
serve as labels. The enhancement model can be trained with this generated dataset. The scales JSON file looks like this:
@ -42,6 +49,7 @@ serve as labels. The enhancement model can be trained with this generated datase
```
### machine-based-reading-order
For machine-based reading order, we aim to determine the reading priority between two sets of text regions. The model's
input is a three-channel image: the first and last channels contain information about each of the two text regions,
while the middle channel encodes prominent layout elements necessary for reading order, such as separators and headers.
@ -52,10 +60,18 @@ For output images, it is necessary to specify the width and height. Additionally
to filter out regions smaller than this minimum size. This minimum size is defined as the ratio of the text region area
to the image area, with a default value of zero. To run the dataset generator, use the following command:
`python generate_gt_for_training.py machine-based-reading-order -dx "dir of GT xml files" -domi "dir where output images
will be written" -docl "dir where the labels will be written" -ih "height" -iw "width" -min "min area ratio"`
```shell
python generate_gt_for_training.py machine-based-reading-order \
-dx "dir of GT xml files" \
-domi "dir where output images will be written" \
-docl "dir where the labels will be written" \
-ih "height" \
-iw "width" \
-min "min area ratio"
```
### pagexml2label
pagexml2label is designed to generate labels from GT page XML files for various pixel-wise segmentation use cases,
including 'layout,' 'textline,' 'printspace,' 'glyph,' and 'word' segmentation.
To train a pixel-wise segmentation model, we require images along with their corresponding labels. Our training script
@ -119,9 +135,13 @@ graphic region, "stamp" has its own class, while all other types are classified
region" are also present in the label. However, other regions like "noise region" and "table region" will not be
included in the label PNG file, even if they have information in the page XML files, as we chose not to include them.
`python generate_gt_for_training.py pagexml2label -dx "dir of GT xml files" -do "dir where output label png files will
be written" -cfg "custom config json file" -to "output type which has 2d and 3d. 2d is used for training and 3d is just
to visualise the labels" "`
```sh
python generate_gt_for_training.py pagexml2label \
-dx "dir of GT xml files" \
-do "dir where output label png files will be written" \
-cfg "custom config json file" \
-to "output type which has 2d and 3d. 2d is used for training and 3d is just to visualise the labels"
```
We have also defined an artificial class that can be added to the boundary of text region types or text lines. This key
is called "artificial_class_on_boundary." If users want to apply this to certain text regions in the layout use case,
@ -169,12 +189,19 @@ in this scenario, since cropping will be applied to the label files, the directo
provided to ensure that they are cropped in sync with the labels. This ensures that the correct images and labels
required for training are obtained. The command should resemble the following:
`python generate_gt_for_training.py pagexml2label -dx "dir of GT xml files" -do "dir where output label png files will
be written" -cfg "custom config json file" -to "output type which has 2d and 3d. 2d is used for training and 3d is just
to visualise the labels" -ps -di "dir where the org images are located" -doi "dir where the cropped output images will
be written" `
```sh
python generate_gt_for_training.py pagexml2label \
-dx "dir of GT xml files" \
-do "dir where output label png files will be written" \
-cfg "custom config json file" \
-to "output type which has 2d and 3d. 2d is used for training and 3d is just to visualise the labels" \
-ps \
-di "dir where the org images are located" \
-doi "dir where the cropped output images will be written"
```
## Train a model
### classification
For the classification use case, we haven't provided a ground truth generator, as it's unnecessary. For classification,
@ -225,7 +252,9 @@ And the "dir_eval" the same structure as train directory:
The classification model can be trained using the following command line:
`python train.py with config_classification.json`
```sh
python train.py with config_classification.json
```
As evident in the example JSON file above, for classification, we utilize a "f1_threshold_classification" parameter.
This parameter is employed to gather all models with an evaluation f1 score surpassing this threshold. Subsequently,
@ -276,6 +305,7 @@ The classification model can be trained like the classification case command lin
### Segmentation (Textline, Binarization, Page extraction and layout) and enhancement
#### Parameter configuration for segmentation or enhancement usecases
The following parameter configuration can be applied to all segmentation use cases and enhancements. The augmentation,
its sub-parameters, and continued training are defined only for segmentation use cases and enhancements, not for
classification and machine-based reading order, as you can see in their example config files.
@ -355,6 +385,7 @@ command, similar to the process for classification and reading order:
`python train.py with config_classification.json`
#### Binarization
An example config json file for binarization can be like this:
```yaml
@ -550,6 +581,7 @@ For page segmentation (or printspace or border segmentation), the model needs to
hence the patches parameter should be set to false.
#### layout segmentation
An example config json file for layout segmentation with 5 classes (including background) can be like this:
```yaml
@ -605,26 +637,41 @@ An example config json file for layout segmentation with 5 classes (including ba
## Inference with the trained model
### classification
For conducting inference with a trained model, you simply need to execute the following command line, specifying the
directory of the model and the image on which to perform inference:
`python inference.py -m "model dir" -i "image" `
```sh
python inference.py -m "model dir" -i "image"
```
This will straightforwardly return the class of the image.
### machine based reading order
To infer the reading order using a reading order model, we need a page XML file containing layout information but
without the reading order. We simply need to provide the model directory, the XML file, and the output directory.
The new XML file with the added reading order will be written to the output directory with the same name.
We need to run:
`python inference.py -m "model dir" -xml "page xml file" -o "output dir to write new xml with reading order" `
```sh
python inference.py \
-m "model dir" \
-xml "page xml file" \
-o "output dir to write new xml with reading order"
```
### Segmentation (Textline, Binarization, Page extraction and layout) and enhancement
For conducting inference with a trained model for segmentation and enhancement you need to run the following command
line:
`python inference.py -m "model dir" -i "image" -p -s "output image" `
```sh
python inference.py \
-m "model dir" \
-i "image" \
-p \
-s "output image"
```
Note that in the case of page extraction the -p flag is not needed.

View file

@ -46,7 +46,7 @@ optional-dependencies.test = {file = ["requirements-test.txt"]}
where = ["src"]
[tool.setuptools.package-data]
"*" = ["*.json", '*.yml', '*.xml', '*.xsd']
"*" = ["*.json", '*.yml', '*.xml', '*.xsd', '*.ttf']
[tool.coverage.run]
branch = true

Binary file not shown.

View file

@ -1,5 +1,6 @@
import sys
import click
import logging
from ocrd_utils import initLogging, getLevelName, getLogger
from eynollah.eynollah import Eynollah, Eynollah_ocr
from eynollah.sbb_binarize import SbbBinarizer
@ -12,22 +13,23 @@ def main():
@main.command()
@click.option(
"--dir_xml",
"-dx",
help="directory of page-xml files",
type=click.Path(exists=True, file_okay=False),
)
@click.option(
"--xml_file",
"-xml",
help="xml filename",
"--input",
"-i",
help="PAGE-XML input filename",
type=click.Path(exists=True, dir_okay=False),
)
@click.option(
"--dir_out",
"-do",
"--dir_in",
"-di",
help="directory of PAGE-XML input files (instead of --input)",
type=click.Path(exists=True, file_okay=False),
)
@click.option(
"--out",
"-o",
help="directory for output images",
type=click.Path(exists=True, file_okay=False),
required=True,
)
@click.option(
"--model",
@ -36,53 +38,73 @@ def main():
type=click.Path(exists=True, file_okay=False),
required=True,
)
@click.option(
"--log_level",
"-l",
type=click.Choice(['OFF', 'DEBUG', 'INFO', 'WARN', 'ERROR']),
help="Override log level globally to this",
)
def machine_based_reading_order(dir_xml, xml_file, dir_out, model):
raedingorder_object = machine_based_reading_order_on_layout(model, dir_out=dir_out, logger=getLogger('enhancement'))
def machine_based_reading_order(input, dir_in, out, model, log_level):
assert bool(input) != bool(dir_in), "Either -i (single input) or -di (directory) must be provided, but not both."
orderer = machine_based_reading_order_on_layout(model)
if log_level:
orderer.logger.setLevel(getLevelName(log_level))
if dir_xml:
raedingorder_object.run(dir_in=dir_xml)
else:
raedingorder_object.run(xml_filename=xml_file)
orderer.run(xml_filename=input,
dir_in=dir_in,
dir_out=out,
)
@main.command()
@click.option('--patches/--no-patches', default=True, help='by enabling this parameter you let the model to see the image in patches.')
@click.option('--model_dir', '-m', type=click.Path(exists=True, file_okay=False), required=True, help='directory containing models for prediction')
@click.argument('input_image', required=False)
@click.argument('output_image', required=False)
@click.option(
"--input-image", "--image",
"-i",
help="input image filename",
type=click.Path(exists=True, dir_okay=False)
)
@click.option(
"--dir_in",
"-di",
help="directory of input images",
help="directory of input images (instead of --image)",
type=click.Path(exists=True, file_okay=False),
)
@click.option(
"--dir_out",
"-do",
help="directory for output images",
type=click.Path(exists=True, file_okay=False),
"--output",
"-o",
help="output image (if using -i) or output image directory (if using -di)",
type=click.Path(file_okay=True, dir_okay=True),
required=True,
)
def binarization(patches, model_dir, input_image, output_image, dir_in, dir_out):
assert (dir_out is None) == (dir_in is None), "Options -di and -do are mutually dependent"
assert (input_image is None) == (output_image is None), "INPUT_IMAGE and OUTPUT_IMAGE are mutually dependent"
assert (dir_in is None) != (input_image is None), "Specify either -di and -do options, or INPUT_IMAGE and OUTPUT_IMAGE"
SbbBinarizer(model_dir).run(image_path=input_image, use_patches=patches, save=output_image, dir_in=dir_in, dir_out=dir_out)
@click.option(
"--log_level",
"-l",
type=click.Choice(['OFF', 'DEBUG', 'INFO', 'WARN', 'ERROR']),
help="Override log level globally to this",
)
def binarization(patches, model_dir, input_image, dir_in, output, log_level):
assert bool(input_image) != bool(dir_in), "Either -i (single input) or -di (directory) must be provided, but not both."
binarizer = SbbBinarizer(model_dir)
if log_level:
binarizer.log.setLevel(getLevelName(log_level))
binarizer.run(image_path=input_image, use_patches=patches, output=output, dir_in=dir_in)
@main.command()
@click.option(
"--image",
"-i",
help="image filename",
help="input image filename",
type=click.Path(exists=True, dir_okay=False),
)
@click.option(
"--out",
"-o",
help="directory to write output xml data",
help="directory for output PAGE-XML files",
type=click.Path(exists=True, file_okay=False),
required=True,
)
@ -95,7 +117,7 @@ def binarization(patches, model_dir, input_image, output_image, dir_in, dir_out)
@click.option(
"--dir_in",
"-di",
help="directory of images",
help="directory of input images (instead of --image)",
type=click.Path(exists=True, file_okay=False),
)
@click.option(
@ -130,35 +152,34 @@ def binarization(patches, model_dir, input_image, output_image, dir_in, dir_out)
)
def enhancement(image, out, overwrite, dir_in, model, num_col_upper, num_col_lower, save_org_scale, log_level):
assert bool(image) != bool(dir_in), "Either -i (single input) or -di (directory) must be provided, but not both."
initLogging()
if log_level:
getLogger('enhancement').setLevel(getLevelName(log_level))
assert image or dir_in, "Either a single image -i or a dir_in -di is required"
enhancer_object = Enhancer(
enhancer = Enhancer(
model,
logger=getLogger('enhancement'),
dir_out=out,
num_col_upper=num_col_upper,
num_col_lower=num_col_lower,
save_org_scale=save_org_scale,
)
if dir_in:
enhancer_object.run(dir_in=dir_in, overwrite=overwrite)
else:
enhancer_object.run(image_filename=image, overwrite=overwrite)
if log_level:
enhancer.logger.setLevel(getLevelName(log_level))
enhancer.run(overwrite=overwrite,
dir_in=dir_in,
image_filename=image,
dir_out=out,
)
@main.command()
@click.option(
"--image",
"-i",
help="image filename",
help="input image filename",
type=click.Path(exists=True, dir_okay=False),
)
@click.option(
"--out",
"-o",
help="directory to write output xml data",
help="directory for output PAGE-XML files",
type=click.Path(exists=True, file_okay=False),
required=True,
)
@ -171,7 +192,7 @@ def enhancement(image, out, overwrite, dir_in, model, num_col_upper, num_col_low
@click.option(
"--dir_in",
"-di",
help="directory of images",
help="directory of input images (instead of --image)",
type=click.Path(exists=True, file_okay=False),
)
@click.option(
@ -338,17 +359,30 @@ def enhancement(image, out, overwrite, dir_in, model, num_col_upper, num_col_low
is_flag=True,
help="if this parameter set to true, this tool will ignore layout detection and reading order. It means that textline detection will be done within printspace and contours of textline will be written in xml output file.",
)
# TODO move to top-level CLI context
@click.option(
"--log_level",
"-l",
type=click.Choice(['OFF', 'DEBUG', 'INFO', 'WARN', 'ERROR']),
help="Override log level globally to this",
help="Override 'eynollah' log level globally to this",
)
#
@click.option(
"--setup-logging",
is_flag=True,
help="Setup a basic console logger",
)
def layout(image, out, overwrite, dir_in, model, save_images, save_layout, save_deskewed, save_all, extract_only_images, save_page, enable_plotting, allow_enhancement, curved_line, textline_light, full_layout, tables, right2left, input_binary, allow_scaling, headers_off, light_version, reading_order_machine_based, do_ocr, transformer_ocr, batch_size_ocr, num_col_upper, num_col_lower, threshold_art_class_textline, threshold_art_class_layout, skip_layout_and_reading_order, ignore_page_extraction, log_level):
def layout(image, out, overwrite, dir_in, model, save_images, save_layout, save_deskewed, save_all, extract_only_images, save_page, enable_plotting, allow_enhancement, curved_line, textline_light, full_layout, tables, right2left, input_binary, allow_scaling, headers_off, light_version, reading_order_machine_based, do_ocr, transformer_ocr, batch_size_ocr, num_col_upper, num_col_lower, threshold_art_class_textline, threshold_art_class_layout, skip_layout_and_reading_order, ignore_page_extraction, log_level, setup_logging):
if setup_logging:
console_handler = logging.StreamHandler(sys.stdout)
console_handler.setLevel(logging.INFO)
formatter = logging.Formatter('%(message)s')
console_handler.setFormatter(formatter)
getLogger('eynollah').addHandler(console_handler)
getLogger('eynollah').setLevel(logging.INFO)
else:
initLogging()
if log_level:
getLogger('eynollah').setLevel(getLevelName(log_level))
assert enable_plotting or not save_layout, "Plotting with -sl also requires -ep"
assert enable_plotting or not save_deskewed, "Plotting with -sd also requires -ep"
assert enable_plotting or not save_all, "Plotting with -sa also requires -ep"
@ -367,17 +401,10 @@ def layout(image, out, overwrite, dir_in, model, save_images, save_layout, save_
assert not extract_only_images or not tables, "Image extraction -eoi can not be set alongside tables -tab"
assert not extract_only_images or not right2left, "Image extraction -eoi can not be set alongside right2left -r2l"
assert not extract_only_images or not headers_off, "Image extraction -eoi can not be set alongside headers_off -ho"
assert image or dir_in, "Either a single image -i or a dir_in -di is required"
assert bool(image) != bool(dir_in), "Either -i (single input) or -di (directory) must be provided, but not both."
eynollah = Eynollah(
model,
logger=getLogger('eynollah'),
dir_out=out,
dir_of_cropped_images=save_images,
extract_only_images=extract_only_images,
dir_of_layout=save_layout,
dir_of_deskewed=save_deskewed,
dir_of_all=save_all,
dir_save_page=save_page,
enable_plotting=enable_plotting,
allow_enhancement=allow_enhancement,
curved_line=curved_line,
@ -400,56 +427,64 @@ def layout(image, out, overwrite, dir_in, model, save_images, save_layout, save_
threshold_art_class_textline=threshold_art_class_textline,
threshold_art_class_layout=threshold_art_class_layout,
)
if dir_in:
eynollah.run(dir_in=dir_in, overwrite=overwrite)
else:
eynollah.run(image_filename=image, overwrite=overwrite)
if log_level:
eynollah.logger.setLevel(getLevelName(log_level))
eynollah.run(overwrite=overwrite,
image_filename=image,
dir_in=dir_in,
dir_out=out,
dir_of_cropped_images=save_images,
dir_of_layout=save_layout,
dir_of_deskewed=save_deskewed,
dir_of_all=save_all,
dir_save_page=save_page,
)
@main.command()
@click.option(
"--image",
"-i",
help="image filename",
help="input image filename",
type=click.Path(exists=True, dir_okay=False),
)
@click.option(
"--dir_in",
"-di",
help="directory of input images (instead of --image)",
type=click.Path(exists=True, file_okay=False),
)
@click.option(
"--dir_in_bin",
"-dib",
help="directory of binarized images (in addition to --dir_in for RGB images; filename stems must match the RGB image files, with '.png' suffix).\nPerform prediction using both RGB and binary images. (This does not necessarily improve results, however it may be beneficial for certain document images.)",
type=click.Path(exists=True, file_okay=False),
)
@click.option(
"--dir_xmls",
"-dx",
help="directory of input PAGE-XML files (in addition to --dir_in; filename stems must match the image files, with '.xml' suffix).",
type=click.Path(exists=True, file_okay=False),
required=True,
)
@click.option(
"--out",
"-o",
help="directory for output PAGE-XML files",
type=click.Path(exists=True, file_okay=False),
required=True,
)
@click.option(
"--dir_out_image_text",
"-doit",
help="directory for output images, newly rendered with predicted text",
type=click.Path(exists=True, file_okay=False),
)
@click.option(
"--overwrite",
"-O",
help="overwrite (instead of skipping) if output xml exists",
is_flag=True,
)
@click.option(
"--dir_in",
"-di",
help="directory of images",
type=click.Path(exists=True, file_okay=False),
)
@click.option(
"--dir_in_bin",
"-dib",
help="directory of binarized images. This should be given if you want to do prediction based on both rgb and bin images. And all bin images are png files",
type=click.Path(exists=True, file_okay=False),
)
@click.option(
"--out",
"-o",
help="directory to write output xml data",
type=click.Path(exists=True, file_okay=False),
required=True,
)
@click.option(
"--dir_xmls",
"-dx",
help="directory of xmls",
type=click.Path(exists=True, file_okay=False),
)
@click.option(
"--dir_out_image_text",
"-doit",
help="directory of images with predicted text",
type=click.Path(exists=True, file_okay=False),
)
@click.option(
"--model",
"-m",
@ -479,12 +514,6 @@ def layout(image, out, overwrite, dir_in, model, save_images, save_layout, save_
is_flag=True,
help="if this parameter set to true, cropped textline images will not be masked with textline contour.",
)
@click.option(
"--prediction_with_both_of_rgb_and_bin",
"-brb/-nbrb",
is_flag=True,
help="If this parameter is set to True, the prediction will be performed using both RGB and binary images. However, this does not necessarily improve results; it may be beneficial for certain document images.",
)
@click.option(
"--batch_size",
"-bs",
@ -507,37 +536,36 @@ def layout(image, out, overwrite, dir_in, model, save_images, save_layout, save_
help="Override log level globally to this",
)
def ocr(image, overwrite, dir_in, dir_in_bin, out, dir_xmls, dir_out_image_text, model, model_name, tr_ocr, export_textline_images_and_text, do_not_mask_with_textline_contour, prediction_with_both_of_rgb_and_bin, batch_size, dataset_abbrevation, min_conf_value_of_textline_text, log_level):
def ocr(image, dir_in, dir_in_bin, dir_xmls, out, dir_out_image_text, overwrite, model, model_name, tr_ocr, export_textline_images_and_text, do_not_mask_with_textline_contour, batch_size, dataset_abbrevation, min_conf_value_of_textline_text, log_level):
initLogging()
if log_level:
getLogger('eynollah').setLevel(getLevelName(log_level))
assert not model or not model_name, "model directory -m can not be set alongside specific model name --model_name"
assert bool(model) != bool(model_name), "Either -m (model directory) or --model_name (specific model name) must be provided."
assert not export_textline_images_and_text or not tr_ocr, "Exporting textline and text -etit can not be set alongside transformer ocr -tr_ocr"
assert not export_textline_images_and_text or not model, "Exporting textline and text -etit can not be set alongside model -m"
assert not export_textline_images_and_text or not batch_size, "Exporting textline and text -etit can not be set alongside batch size -bs"
assert not export_textline_images_and_text or not dir_in_bin, "Exporting textline and text -etit can not be set alongside directory of bin images -dib"
assert not export_textline_images_and_text or not dir_out_image_text, "Exporting textline and text -etit can not be set alongside directory of images with predicted text -doit"
assert not export_textline_images_and_text or not prediction_with_both_of_rgb_and_bin, "Exporting textline and text -etit can not be set alongside prediction with both rgb and bin -brb"
assert (bool(image) ^ bool(dir_in)), "Either -i (single image) or -di (directory) must be provided, but not both."
assert bool(image) != bool(dir_in), "Either -i (single image) or -di (directory) must be provided, but not both."
eynollah_ocr = Eynollah_ocr(
image_filename=image,
dir_xmls=dir_xmls,
dir_out_image_text=dir_out_image_text,
dir_in=dir_in,
dir_in_bin=dir_in_bin,
dir_out=out,
dir_models=model,
model_name=model_name,
tr_ocr=tr_ocr,
export_textline_images_and_text=export_textline_images_and_text,
do_not_mask_with_textline_contour=do_not_mask_with_textline_contour,
prediction_with_both_of_rgb_and_bin=prediction_with_both_of_rgb_and_bin,
batch_size=batch_size,
pref_of_dataset=dataset_abbrevation,
min_conf_value_of_textline_text=min_conf_value_of_textline_text,
)
eynollah_ocr.run(overwrite=overwrite)
if log_level:
eynollah_ocr.logger.setLevel(getLevelName(log_level))
eynollah_ocr.run(overwrite=overwrite,
dir_in=dir_in,
dir_in_bin=dir_in_bin,
image_filename=image,
dir_xmls=dir_xmls,
dir_out_image_text=dir_out_image_text,
dir_out=out,
)
if __name__ == "__main__":
main()

View file

@ -1,4 +1,4 @@
# pylint: disable=no-member,invalid-name,line-too-long,missing-function-docstring,missing-class-docstring,too-many-branches
#run_single# pylint: disable=no-member,invalid-name,line-too-long,missing-function-docstring,missing-class-docstring,too-many-branches
# pylint: disable=too-many-locals,wrong-import-position,too-many-lines,too-many-statements,chained-comparison,fixme,broad-except,c-extension-no-member
# pylint: disable=too-many-public-methods,too-many-arguments,too-many-instance-attributes,too-many-public-methods,
# pylint: disable=consider-using-enumerate
@ -6,7 +6,13 @@
document layout analysis (segmentation) with output in PAGE-XML
"""
from logging import Logger
# cannot use importlib.resources until we move to 3.9+ forimportlib.resources.files
import sys
if sys.version_info < (3, 10):
import importlib_resources
else:
import importlib.resources as importlib_resources
from difflib import SequenceMatcher as sq
from PIL import Image, ImageDraw, ImageFont
import math
@ -108,6 +114,7 @@ from .utils.drop_capitals import (
from .utils.marginals import get_marginals
from .utils.resize import resize_image
from .utils import (
is_image_filename,
boosting_headers_by_longshot_region_segmentation,
crop_image_inside_box,
find_num_col,
@ -191,13 +198,7 @@ class Eynollah:
def __init__(
self,
dir_models : str,
dir_out : Optional[str] = None,
dir_of_cropped_images : Optional[str] = None,
extract_only_images : bool =False,
dir_of_layout : Optional[str] = None,
dir_of_deskewed : Optional[str] = None,
dir_of_all : Optional[str] = None,
dir_save_page : Optional[str] = None,
enable_plotting : bool = False,
allow_enhancement : bool = False,
curved_line : bool = False,
@ -219,19 +220,14 @@ class Eynollah:
threshold_art_class_layout: Optional[float] = None,
threshold_art_class_textline: Optional[float] = None,
skip_layout_and_reading_order : bool = False,
logger : Optional[Logger] = None,
):
self.logger = getLogger('eynollah')
self.plotter = None
if skip_layout_and_reading_order:
textline_light = True
self.light_version = light_version
self.dir_out = dir_out
self.dir_of_all = dir_of_all
self.dir_save_page = dir_save_page
self.reading_order_machine_based = reading_order_machine_based
self.dir_of_deskewed = dir_of_deskewed
self.dir_of_deskewed = dir_of_deskewed
self.dir_of_cropped_images=dir_of_cropped_images
self.dir_of_layout=dir_of_layout
self.enable_plotting = enable_plotting
self.allow_enhancement = allow_enhancement
self.curved_line = curved_line
@ -267,10 +263,6 @@ class Eynollah:
else:
self.threshold_art_class_textline = 0.1
self.logger = logger if logger else getLogger('eynollah')
# for parallelization of CPU-intensive tasks:
self.executor = ProcessPoolExecutor(max_workers=cpu_count(), timeout=1200)
atexit.register(self.executor.shutdown)
self.dir_models = dir_models
self.model_dir_of_enhancement = dir_models + "/eynollah-enhancement_20210425"
self.model_dir_of_binarization = dir_models + "/eynollah-binarization_20210425"
@ -326,6 +318,13 @@ class Eynollah:
else:
self.model_table_dir = dir_models + "/eynollah-tables_20210319"
t_start = time.time()
# for parallelization of CPU-intensive tasks:
self.executor = ProcessPoolExecutor(max_workers=cpu_count(), timeout=1200)
atexit.register(self.executor.shutdown)
# #gpu_options = tf.compat.v1.GPUOptions(allow_growth=True)
# #gpu_options = tf.compat.v1.GPUOptions(per_process_gpu_memory_fraction=7.7, allow_growth=True)
# #session = tf.compat.v1.Session(config=tf.compat.v1.ConfigProto(gpu_options=gpu_options))
@ -340,6 +339,8 @@ class Eynollah:
except:
self.logger.warning("no GPU device available")
self.logger.info("Loading models...")
self.model_page = self.our_load_model(self.model_page_dir)
self.model_classifier = self.our_load_model(self.model_dir_of_col_classifier)
self.model_bin = self.our_load_model(self.model_dir_of_binarization)
@ -393,6 +394,8 @@ class Eynollah:
if self.tables:
self.model_table = self.our_load_model(self.model_table_dir)
self.logger.info(f"Model initialization complete ({time.time() - t_start:.1f}s)")
def cache_images(self, image_filename=None, image_pil=None, dpi=None):
ret = {}
t_c0 = time.time()
@ -415,21 +418,11 @@ class Eynollah:
if dpi is not None:
self.dpi = dpi
def reset_file_name_dir(self, image_filename):
def reset_file_name_dir(self, image_filename, dir_out):
t_c = time.time()
self.cache_images(image_filename=image_filename)
self.plotter = None if not self.enable_plotting else EynollahPlotter(
dir_out=self.dir_out,
dir_of_all=self.dir_of_all,
dir_save_page=self.dir_save_page,
dir_of_deskewed=self.dir_of_deskewed,
dir_of_cropped_images=self.dir_of_cropped_images,
dir_of_layout=self.dir_of_layout,
image_filename_stem=Path(Path(image_filename).name).stem)
self.writer = EynollahXmlWriter(
dir_out=self.dir_out,
dir_out=dir_out,
image_filename=image_filename,
curved_line=self.curved_line,
textline_light = self.textline_light)
@ -1747,11 +1740,84 @@ class Eynollah:
self.logger.debug("exit extract_text_regions")
return prediction_regions, prediction_regions2
def get_textlines_of_a_textregion_sorted(self, textlines_textregion, cx_textline, cy_textline, w_h_textline):
N = len(cy_textline)
if N==0:
return []
diff_cy = np.abs( np.diff(sorted(cy_textline)) )
diff_cx = np.abs(np.diff(sorted(cx_textline)) )
if len(diff_cy)>0:
mean_y_diff = np.mean(diff_cy)
mean_x_diff = np.mean(diff_cx)
count_hor = np.count_nonzero(np.array(w_h_textline) > 1)
count_ver = len(w_h_textline) - count_hor
else:
mean_y_diff = 0
mean_x_diff = 0
count_hor = 1
count_ver = 0
if count_hor >= count_ver:
row_threshold = mean_y_diff / 1.5 if mean_y_diff > 0 else 10
indices_sorted_by_y = sorted(range(N), key=lambda i: cy_textline[i])
rows = []
current_row = [indices_sorted_by_y[0]]
for i in range(1, N):
current_idx = indices_sorted_by_y[i]
prev_idx = current_row[0]
if abs(cy_textline[current_idx] - cy_textline[prev_idx]) <= row_threshold:
current_row.append(current_idx)
else:
rows.append(current_row)
current_row = [current_idx]
rows.append(current_row)
sorted_textlines = []
for row in rows:
row_sorted = sorted(row, key=lambda i: cx_textline[i])
for idx in row_sorted:
sorted_textlines.append(textlines_textregion[idx])
else:
row_threshold = mean_x_diff / 1.5 if mean_x_diff > 0 else 10
indices_sorted_by_x = sorted(range(N), key=lambda i: cx_textline[i])
rows = []
current_row = [indices_sorted_by_x[0]]
for i in range(1, N):
current_idy = indices_sorted_by_x[i]
prev_idy = current_row[0]
if abs(cx_textline[current_idy] - cx_textline[prev_idy] ) <= row_threshold:
current_row.append(current_idy)
else:
rows.append(current_row)
current_row = [current_idy]
rows.append(current_row)
sorted_textlines = []
for row in rows:
row_sorted = sorted(row , key=lambda i: cy_textline[i])
for idy in row_sorted:
sorted_textlines.append(textlines_textregion[idy])
return sorted_textlines
def get_slopes_and_deskew_new_light2(self, contours, contours_par, textline_mask_tot, image_page_rotated, boxes, slope_deskew):
polygons_of_textlines = return_contours_of_interested_region(textline_mask_tot,1,0.00001)
M_main_tot = [cv2.moments(polygons_of_textlines[j])
for j in range(len(polygons_of_textlines))]
w_h_textlines = [cv2.boundingRect(polygons_of_textlines[i])[2:] for i in range(len(polygons_of_textlines))]
cx_main_tot = [(M_main_tot[j]["m10"] / (M_main_tot[j]["m00"] + 1e-32)) for j in range(len(M_main_tot))]
cy_main_tot = [(M_main_tot[j]["m01"] / (M_main_tot[j]["m00"] + 1e-32)) for j in range(len(M_main_tot))]
@ -1766,8 +1832,13 @@ class Eynollah:
results = np.array(results)
indexes_in = args_textlines[results==1]
textlines_ins = [polygons_of_textlines[ind] for ind in indexes_in]
cx_textline_in = [cx_main_tot[ind] for ind in indexes_in]
cy_textline_in = [cy_main_tot[ind] for ind in indexes_in]
w_h_textlines_in = [w_h_textlines[ind][0] / float(w_h_textlines[ind][1]) for ind in indexes_in]
all_found_textline_polygons.append(textlines_ins[::-1])
textlines_ins = self.get_textlines_of_a_textregion_sorted(textlines_ins, cx_textline_in, cy_textline_in, w_h_textlines_in)
all_found_textline_polygons.append(textlines_ins)#[::-1])
slopes.append(slope_deskew)
_, crop_coor = crop_image_inside_box(boxes[index],image_page_rotated)
@ -2174,6 +2245,7 @@ class Eynollah:
##mask_texts_only = cv2.dilate(mask_texts_only, KERNEL, iterations=1)
mask_texts_only = cv2.dilate(mask_texts_only, kernel=np.ones((2,2), np.uint8), iterations=1)
mask_images_only=(prediction_regions_org[:,:] ==2)*1
polygons_lines_xml, hir_lines_xml = return_contours_of_image(mask_lines_only)
@ -2209,20 +2281,18 @@ class Eynollah:
text_regions_p_true[:,:][mask_images_only[:,:] == 1] = 2
text_regions_p_true = cv2.fillPoly(text_regions_p_true, pts = polygons_of_only_texts, color=(1,1,1))
#plt.imshow(textline_mask_tot_ea)
#plt.show()
textline_mask_tot_ea[(text_regions_p_true==0) | (text_regions_p_true==4) ] = 0
#plt.imshow(textline_mask_tot_ea)
#plt.show()
#print("inside 4 ", time.time()-t_in)
self.logger.debug("exit get_regions_light_v")
return text_regions_p_true, erosion_hurts, polygons_lines_xml, textline_mask_tot_ea, img_bin, confidence_matrix
return text_regions_p_true, erosion_hurts, polygons_lines_xml, textline_mask_tot_ea, img_bin, confidence_matrix, polygons_of_only_texts
else:
img_bin = resize_image(img_bin,img_height_h, img_width_h )
self.logger.debug("exit get_regions_light_v")
return None, erosion_hurts, None, textline_mask_tot_ea, img_bin, None
return None, erosion_hurts, None, textline_mask_tot_ea, img_bin, None, None
def get_regions_from_xy_2models(self,img,is_image_enhanced, num_col_classifier):
self.logger.debug("enter get_regions_from_xy_2models")
@ -2315,7 +2385,7 @@ class Eynollah:
text_regions_p_true=cv2.fillPoly(text_regions_p_true,pts=polygons_of_only_texts, color=(1,1,1))
self.logger.debug("exit get_regions_from_xy_2models")
return text_regions_p_true, erosion_hurts, polygons_lines_xml
return text_regions_p_true, erosion_hurts, polygons_lines_xml, polygons_of_only_texts
except:
if self.input_binary:
prediction_bin = np.copy(img_org)
@ -2365,7 +2435,7 @@ class Eynollah:
erosion_hurts = True
self.logger.debug("exit get_regions_from_xy_2models")
return text_regions_p_true, erosion_hurts, polygons_lines_xml
return text_regions_p_true, erosion_hurts, polygons_lines_xml, polygons_of_only_texts
def do_order_of_regions_full_layout(
self, contours_only_text_parent, contours_only_text_parent_h, boxes, textline_mask_tot):
@ -4517,27 +4587,68 @@ class Eynollah:
return ordered_left_marginals, ordered_right_marginals, ordered_left_marginals_textline, ordered_right_marginals_textline, ordered_left_marginals_bbox, ordered_right_marginals_bbox, ordered_left_slopes_marginals, ordered_right_slopes_marginals
def run(self, image_filename : Optional[str] = None, dir_in : Optional[str] = None, overwrite : bool = False):
def run(self,
overwrite: bool = False,
image_filename: Optional[str] = None,
dir_in: Optional[str] = None,
dir_out: Optional[str] = None,
dir_of_cropped_images: Optional[str] = None,
dir_of_layout: Optional[str] = None,
dir_of_deskewed: Optional[str] = None,
dir_of_all: Optional[str] = None,
dir_save_page: Optional[str] = None,
):
"""
Get image and scales, then extract the page of scanned image
"""
self.logger.debug("enter run")
t0_tot = time.time()
# Log enabled features directly
enabled_modes = []
if self.light_version:
enabled_modes.append("Light version")
if self.textline_light:
enabled_modes.append("Light textline detection")
if self.full_layout:
enabled_modes.append("Full layout analysis")
if self.ocr:
enabled_modes.append("OCR")
if self.tables:
enabled_modes.append("Table detection")
if enabled_modes:
self.logger.info("Enabled modes: " + ", ".join(enabled_modes))
if self.enable_plotting:
self.logger.info("Saving debug plots")
if dir_of_cropped_images:
self.logger.info(f"Saving cropped images to: {dir_of_cropped_images}")
if dir_of_layout:
self.logger.info(f"Saving layout plots to: {dir_of_layout}")
if dir_of_deskewed:
self.logger.info(f"Saving deskewed images to: {dir_of_deskewed}")
if dir_in:
self.ls_imgs = os.listdir(dir_in)
self.ls_imgs = [ind_img for ind_img in self.ls_imgs if ind_img.endswith('.jpg') or ind_img.endswith('.jpeg') or ind_img.endswith('.png') or ind_img.endswith('.tif') or ind_img.endswith('.tiff') or ind_img.endswith('.JPG') or ind_img.endswith('.JPEG') or ind_img.endswith('.TIF') or ind_img.endswith('.TIFF') or ind_img.endswith('.PNG')]
ls_imgs = [os.path.join(dir_in, image_filename)
for image_filename in filter(is_image_filename,
os.listdir(dir_in))]
elif image_filename:
self.ls_imgs = [image_filename]
ls_imgs = [image_filename]
else:
raise ValueError("run requires either a single image filename or a directory")
for img_filename in self.ls_imgs:
print(img_filename, 'img_filename')
for img_filename in ls_imgs:
self.logger.info(img_filename)
t0 = time.time()
self.reset_file_name_dir(os.path.join(dir_in or "", img_filename))
self.reset_file_name_dir(img_filename, dir_out)
if self.enable_plotting:
self.plotter = EynollahPlotter(dir_out=dir_out,
dir_of_all=dir_of_all,
dir_save_page=dir_save_page,
dir_of_deskewed=dir_of_deskewed,
dir_of_cropped_images=dir_of_cropped_images,
dir_of_layout=dir_of_layout,
image_filename_stem=Path(image_filename).stem)
#print("text region early -11 in %.1fs", time.time() - t0)
if os.path.exists(self.writer.output_filename):
if overwrite:
@ -4548,19 +4659,30 @@ class Eynollah:
pcgts = self.run_single()
self.logger.info("Job done in %.1fs", time.time() - t0)
#print("Job done in %.1fs" % (time.time() - t0))
self.writer.write_pagexml(pcgts)
if dir_in:
self.logger.info("All jobs done in %.1fs", time.time() - t0_tot)
print("all Job done in %.1fs", time.time() - t0_tot)
def run_single(self):
t0 = time.time()
img_res, is_image_enhanced, num_col_classifier, num_column_is_classified = self.run_enhancement(self.light_version)
self.logger.info("Enhancing took %.1fs ", time.time() - t0)
self.logger.info(f"Processing file: {self.writer.image_filename}")
self.logger.info("Step 1/5: Image Enhancement")
img_res, is_image_enhanced, num_col_classifier, num_column_is_classified = self.run_enhancement(self.light_version)
self.logger.info(f"Image: {self.image.shape[1]}x{self.image.shape[0]}, {self.dpi} DPI, {num_col_classifier} columns")
if is_image_enhanced:
self.logger.info("Enhancement applied")
self.logger.info(f"Enhancement complete ({time.time() - t0:.1f}s)")
# Image Extraction Mode
if self.extract_only_images:
self.logger.info("Step 2/5: Image Extraction Mode")
text_regions_p_1, erosion_hurts, polygons_lines_xml, polygons_of_images, image_page, page_coord, cont_page = \
self.get_regions_light_v_extract_only_images(img_res, is_image_enhanced, num_col_classifier)
pcgts = self.writer.build_pagexml_no_full_layout(
@ -4569,10 +4691,16 @@ class Eynollah:
cont_page, [], [])
if self.plotter:
self.plotter.write_images_into_directory(polygons_of_images, image_page)
self.logger.info("Image extraction complete")
return pcgts
# Basic Processing Mode
if self.skip_layout_and_reading_order:
_ ,_, _, textline_mask_tot_ea, img_bin_light, _ = \
self.logger.info("Step 2/5: Basic Processing Mode")
self.logger.info("Skipping layout analysis and reading order detection")
_ ,_, _, textline_mask_tot_ea, img_bin_light, _,_= \
self.get_regions_light_v(img_res, is_image_enhanced, num_col_classifier,
skip_layout_and_reading_order=self.skip_layout_and_reading_order)
@ -4585,7 +4713,14 @@ class Eynollah:
all_found_textline_polygons = filter_contours_area_of_image(
textline_mask_tot_ea, cnt_clean_rot_raw, hir_on_cnt_clean_rot, max_area=1, min_area=0.00001)
all_found_textline_polygons = all_found_textline_polygons[::-1]
M_main_tot = [cv2.moments(all_found_textline_polygons[j])
for j in range(len(all_found_textline_polygons))]
w_h_textlines = [cv2.boundingRect(all_found_textline_polygons[j])[2:] for j in range(len(all_found_textline_polygons))]
w_h_textlines = [w_h_textlines[j][0] / float(w_h_textlines[j][1]) for j in range(len(w_h_textlines))]
cx_main_tot = [(M_main_tot[j]["m10"] / (M_main_tot[j]["m00"] + 1e-32)) for j in range(len(M_main_tot))]
cy_main_tot = [(M_main_tot[j]["m01"] / (M_main_tot[j]["m00"] + 1e-32)) for j in range(len(M_main_tot))]
all_found_textline_polygons = self.get_textlines_of_a_textregion_sorted(all_found_textline_polygons, cx_main_tot, cy_main_tot, w_h_textlines)#all_found_textline_polygons[::-1]
all_found_textline_polygons=[ all_found_textline_polygons ]
@ -4623,12 +4758,16 @@ class Eynollah:
all_found_textline_polygons, page_coord, polygons_of_images, polygons_of_marginals_left, polygons_of_marginals_right,
all_found_textline_polygons_marginals_left, all_found_textline_polygons_marginals_right, all_box_coord_marginals_left, all_box_coord_marginals_right, slopes, slopes_marginals_left, slopes_marginals_right,
cont_page, polygons_lines_xml, contours_tables, ocr_all_textlines=ocr_all_textlines, conf_contours_textregion=conf_contours_textregions, skip_layout_reading_order=self.skip_layout_and_reading_order)
self.logger.info("Basic processing complete")
return pcgts
#print("text region early -1 in %.1fs", time.time() - t0)
t1 = time.time()
self.logger.info("Step 2/5: Layout Analysis")
if self.light_version:
text_regions_p_1 ,erosion_hurts, polygons_lines_xml, textline_mask_tot_ea, img_bin_light, confidence_matrix = \
self.logger.info("Using light version processing")
text_regions_p_1 ,erosion_hurts, polygons_lines_xml, textline_mask_tot_ea, img_bin_light, confidence_matrix, polygons_text_early = \
self.get_regions_light_v(img_res, is_image_enhanced, num_col_classifier)
#print("text region early -2 in %.1fs", time.time() - t0)
@ -4653,24 +4792,27 @@ class Eynollah:
#self.logger.info("run graphics %.1fs ", time.time() - t1t)
#print("text region early -3 in %.1fs", time.time() - t0)
textline_mask_tot_ea_org = np.copy(textline_mask_tot_ea)
#print("text region early -4 in %.1fs", time.time() - t0)
else:
text_regions_p_1 ,erosion_hurts, polygons_lines_xml = \
text_regions_p_1 ,erosion_hurts, polygons_lines_xml, polygons_text_early = \
self.get_regions_from_xy_2models(img_res, is_image_enhanced,
num_col_classifier)
self.logger.info("Textregion detection took %.1fs ", time.time() - t1)
self.logger.info(f"Textregion detection took {time.time() - t1:.1f}s")
confidence_matrix = np.zeros((text_regions_p_1.shape[:2]))
t1 = time.time()
num_col, num_col_classifier, img_only_regions, page_coord, image_page, mask_images, mask_lines, \
text_regions_p_1, cont_page, table_prediction = \
self.run_graphics_and_columns(text_regions_p_1, num_col_classifier, num_column_is_classified, erosion_hurts)
self.logger.info("Graphics detection took %.1fs ", time.time() - t1)
self.logger.info(f"Graphics detection took {time.time() - t1:.1f}s")
#self.logger.info('cont_page %s', cont_page)
#plt.imshow(table_prediction)
#plt.show()
if not num_col:
self.logger.info("No columns detected, outputting an empty PAGE-XML")
self.logger.info(f"Layout analysis complete ({time.time() - t1:.1f}s)")
if not num_col and len(polygons_text_early) == 0:
self.logger.info("No columns detected - generating empty PAGE-XML")
pcgts = self.writer.build_pagexml_no_full_layout(
[], page_coord, [], [], [], [], [], [], [], [], [], [], [], [], [], [],
cont_page, [], [])
@ -4680,10 +4822,12 @@ class Eynollah:
t1 = time.time()
if not self.light_version:
textline_mask_tot_ea = self.run_textline(image_page)
self.logger.info("textline detection took %.1fs", time.time() - t1)
self.logger.info(f"Textline detection took {time.time() - t1:.1f}s")
t1 = time.time()
slope_deskew, slope_first = self.run_deskew(textline_mask_tot_ea)
self.logger.info("deskewing took %.1fs", time.time() - t1)
if np.abs(slope_deskew) > 0.01: # Only log if there is significant skew
self.logger.info(f"Applied deskew correction: {slope_deskew:.2f} degrees")
self.logger.info(f"Deskewing took {time.time() - t1:.1f}s")
elif num_col_classifier in (1,2):
org_h_l_m = textline_mask_tot_ea.shape[0]
org_w_l_m = textline_mask_tot_ea.shape[1]
@ -4704,6 +4848,22 @@ class Eynollah:
self.run_marginals(image_page, textline_mask_tot_ea, mask_images, mask_lines,
num_col_classifier, slope_deskew, text_regions_p_1, table_prediction)
if image_page.shape[0]!=0 and image_page.shape[1]!=0:
# if ratio of text regions to page area is smaller that 0.3, deskew angle is not aloowed to exceed 45
if ( ( text_regions_p[:,:]==1).sum() + (text_regions_p[:,:]==4).sum() ) / float(image_page.shape[0]*image_page.shape[1] ) <= 0.3 and abs(slope_deskew) > 45:
slope_deskew = 0
if (text_regions_p[:,:]==1).sum() == 0:
text_regions_p[:,:][text_regions_p[:,:]==4] = 1
self.logger.info("Step 3/5: Text Line Detection")
if self.curved_line:
self.logger.info("Mode: Curved line detection")
elif self.textline_light:
self.logger.info("Mode: Light detection")
if self.light_version and num_col_classifier in (1,2):
image_page = resize_image(image_page,org_h_l_m, org_w_l_m )
textline_mask_tot_ea = resize_image(textline_mask_tot_ea,org_h_l_m, org_w_l_m )
@ -4713,8 +4873,7 @@ class Eynollah:
table_prediction = resize_image(table_prediction,org_h_l_m, org_w_l_m )
image_page_rotated = resize_image(image_page_rotated,org_h_l_m, org_w_l_m )
self.logger.info("detection of marginals took %.1fs", time.time() - t1)
#print("text region early 2 marginal in %.1fs", time.time() - t0)
self.logger.info(f"Detection of marginals took {time.time() - t1:.1f}s")
## birdan sora chock chakir
t1 = time.time()
if not self.full_layout:
@ -4743,6 +4902,8 @@ class Eynollah:
###min_con_area = 0.000005
contours_only_text, hir_on_text = return_contours_of_image(text_only)
contours_only_text_parent = return_parent_contours(contours_only_text, hir_on_text)
if len(contours_only_text_parent) > 0:
areas_cnt_text = np.array([cv2.contourArea(c) for c in contours_only_text_parent])
areas_cnt_text = areas_cnt_text / float(text_only.shape[0] * text_only.shape[1])
@ -4812,7 +4973,7 @@ class Eynollah:
cx_bigest_d_big[0] = cx_bigest_d[ind_largest]
cy_biggest_d_big[0] = cy_biggest_d[ind_largest]
except Exception as why:
self.logger.error(why)
self.logger.error(str(why))
(h, w) = text_only.shape[:2]
center = (w // 2.0, h // 2.0)
@ -4845,6 +5006,8 @@ class Eynollah:
contours_only_text_parent_d = []
#contours_only_text_parent = []
boxes_marginals, _ = get_text_region_boxes_by_given_contours(polygons_of_marginals)
if not len(contours_only_text_parent):
# stop early
empty_marginals = [[]] * len(polygons_of_marginals)
@ -4880,7 +5043,6 @@ class Eynollah:
contours_only_text_parent, self.image, slope_first, confidence_matrix, map=self.executor.map)
#print("text region early 4 in %.1fs", time.time() - t0)
boxes_text, _ = get_text_region_boxes_by_given_contours(contours_only_text_parent)
boxes_marginals, _ = get_text_region_boxes_by_given_contours(polygons_of_marginals)
#print("text region early 5 in %.1fs", time.time() - t0)
## birdan sora chock chakir
if not self.curved_line:
@ -5034,6 +5196,15 @@ class Eynollah:
t_order = time.time()
if self.full_layout:
self.logger.info("Step 4/5: Reading Order Detection")
if self.reading_order_machine_based:
self.logger.info("Using machine-based detection")
if self.right2left:
self.logger.info("Right-to-left mode enabled")
if self.headers_off:
self.logger.info("Headers ignored in reading order")
if self.reading_order_machine_based:
tror = time.time()
order_text_new, id_of_texts_tot = self.do_order_of_regions_with_model(
@ -5045,9 +5216,16 @@ class Eynollah:
else:
order_text_new, id_of_texts_tot = self.do_order_of_regions(
contours_only_text_parent_d_ordered, contours_only_text_parent_h_d_ordered, boxes_d, textline_mask_tot_d)
self.logger.info("detection of reading order took %.1fs", time.time() - t_order)
self.logger.info(f"Detection of reading order took {time.time() - t_order:.1f}s")
if self.ocr and not self.tr:
self.logger.info("Step 4.5/5: OCR Processing")
if torch.cuda.is_available():
self.logger.info("Using GPU acceleration")
else:
self.logger.info("Using CPU processing")
gc.collect()
if len(all_found_textline_polygons)>0:
ocr_all_textlines = return_rnn_cnn_ocr_of_given_textlines(image_page, all_found_textline_polygons, self.prediction_model, self.b_s_ocr, self.num_to_char, self.textline_light, self.curved_line)
@ -5079,15 +5257,28 @@ class Eynollah:
ocr_all_textlines_marginals_right = None
ocr_all_textlines_h = None
ocr_all_textlines_drop = None
self.logger.info("Step 5/5: Output Generation")
pcgts = self.writer.build_pagexml_full_layout(
contours_only_text_parent, contours_only_text_parent_h, page_coord, order_text_new, id_of_texts_tot,
all_found_textline_polygons, all_found_textline_polygons_h, all_box_coord, all_box_coord_h,
polygons_of_images, contours_tables, polygons_of_drop_capitals, polygons_of_marginals_left, polygons_of_marginals_right,
all_found_textline_polygons_marginals_left, all_found_textline_polygons_marginals_right, all_box_coord_marginals_left, all_box_coord_marginals_right, slopes, slopes_h, slopes_marginals_left, slopes_marginals_right,
cont_page, polygons_lines_xml, ocr_all_textlines, ocr_all_textlines_h, ocr_all_textlines_marginals_left, ocr_all_textlines_marginals_right, ocr_all_textlines_drop, conf_contours_textregions, conf_contours_textregions_h)
return pcgts
contours_only_text_parent_h = None
self.logger.info("Step 4/5: Reading Order Detection")
if self.reading_order_machine_based:
self.logger.info("Using machine-based detection")
if self.right2left:
self.logger.info("Right-to-left mode enabled")
if self.headers_off:
self.logger.info("Headers ignored in reading order")
if self.reading_order_machine_based:
order_text_new, id_of_texts_tot = self.do_order_of_regions_with_model(
contours_only_text_parent, contours_only_text_parent_h, text_regions_p)
@ -5108,6 +5299,21 @@ class Eynollah:
contours_only_text_parent_d_ordered, contours_only_text_parent_h, boxes_d, textline_mask_tot_d)
if self.ocr and self.tr:
self.logger.info("Step 4.5/5: OCR Processing")
if torch.cuda.is_available():
self.logger.info("Using GPU acceleration")
else:
self.logger.info("Using CPU processing")
if self.light_version:
self.logger.info("Using light version OCR")
if self.textline_light:
self.logger.info("Using light text line detection for OCR")
self.logger.info("Processing text lines...")
device = cuda.get_current_device()
device.reset()
gc.collect()
@ -5170,13 +5376,19 @@ class Eynollah:
ocr_all_textlines = None
ocr_all_textlines_marginals_left = None
ocr_all_textlines_marginals_right = None
self.logger.info("detection of reading order took %.1fs", time.time() - t_order)
self.logger.info(f"Detection of reading order took {time.time() - t_order:.1f}s")
self.logger.info("Step 5/5: Output Generation")
self.logger.info("Generating PAGE-XML output")
pcgts = self.writer.build_pagexml_no_full_layout(
txt_con_org, page_coord, order_text_new, id_of_texts_tot,
all_found_textline_polygons, all_box_coord, polygons_of_images, polygons_of_marginals_left, polygons_of_marginals_right,
all_found_textline_polygons_marginals_left, all_found_textline_polygons_marginals_right, all_box_coord_marginals_left, all_box_coord_marginals_right, slopes, slopes_marginals_left, slopes_marginals_right,
cont_page, polygons_lines_xml, contours_tables, ocr_all_textlines, ocr_all_textlines_marginals_left, ocr_all_textlines_marginals_right, conf_contours_textregions)
self.logger.info(f"Output file: {self.writer.output_filename}")
return pcgts
@ -5186,32 +5398,19 @@ class Eynollah_ocr:
dir_models,
model_name=None,
dir_xmls=None,
dir_in=None,
image_filename=None,
dir_in_bin=None,
dir_out=None,
dir_out_image_text=None,
tr_ocr=False,
batch_size=None,
export_textline_images_and_text=False,
do_not_mask_with_textline_contour=False,
prediction_with_both_of_rgb_and_bin=False,
pref_of_dataset=None,
min_conf_value_of_textline_text : Optional[float]=None,
logger=None,
):
self.dir_in = dir_in
self.image_filename = image_filename
self.dir_in_bin = dir_in_bin
self.dir_out = dir_out
self.dir_xmls = dir_xmls
self.dir_models = dir_models
self.model_name = model_name
self.tr_ocr = tr_ocr
self.export_textline_images_and_text = export_textline_images_and_text
self.do_not_mask_with_textline_contour = do_not_mask_with_textline_contour
self.dir_out_image_text = dir_out_image_text
self.prediction_with_both_of_rgb_and_bin = prediction_with_both_of_rgb_and_bin
self.pref_of_dataset = pref_of_dataset
self.logger = logger if logger else getLogger('eynollah')
@ -5263,24 +5462,27 @@ class Eynollah_ocr:
)
self.end_character = len(characters) + 2
def run(self, overwrite : bool = False):
if self.dir_in:
ls_imgs = os.listdir(self.dir_in)
ls_imgs = [ind_img for ind_img in ls_imgs if ind_img.endswith('.jpg') or ind_img.endswith('.jpeg') or ind_img.endswith('.png') or ind_img.endswith('.tif') or ind_img.endswith('.tiff') or ind_img.endswith('.JPG') or ind_img.endswith('.JPEG') or ind_img.endswith('.TIF') or ind_img.endswith('.TIFF') or ind_img.endswith('.PNG')]
def run(self, overwrite: bool = False,
dir_in: Optional[str] = None,
dir_in_bin: Optional[str] = None,
image_filename: Optional[str] = None,
dir_xmls: Optional[str] = None,
dir_out_image_text: Optional[str] = None,
dir_out: Optional[str] = None,
):
if dir_in:
ls_imgs = [os.path.join(dir_in, image_filename)
for image_filename in filter(is_image_filename,
os.listdir(dir_in))]
else:
ls_imgs = [self.image_filename]
ls_imgs = [image_filename]
if self.tr_ocr:
tr_ocr_input_height_and_width = 384
for ind_img in ls_imgs:
if self.dir_in:
file_name = Path(ind_img).stem
dir_img = os.path.join(self.dir_in, ind_img)
else:
file_name = Path(self.image_filename).stem
dir_img = self.image_filename
dir_xml = os.path.join(self.dir_xmls, file_name+'.xml')
out_file_ocr = os.path.join(self.dir_out, file_name+'.xml')
for dir_img in ls_imgs:
file_name = Path(dir_img).stem
dir_xml = os.path.join(dir_xmls, file_name+'.xml')
out_file_ocr = os.path.join(dir_out, file_name+'.xml')
if os.path.exists(out_file_ocr):
if overwrite:
@ -5291,8 +5493,8 @@ class Eynollah_ocr:
img = cv2.imread(dir_img)
if self.dir_out_image_text:
out_image_with_text = os.path.join(self.dir_out_image_text, file_name+'.png')
if dir_out_image_text:
out_image_with_text = os.path.join(dir_out_image_text, file_name+'.png')
image_text = Image.new("RGB", (img.shape[1], img.shape[0]), "white")
draw = ImageDraw.Draw(image_text)
total_bb_coordinates = []
@ -5330,7 +5532,7 @@ class Eynollah_ocr:
textline_coords = np.array( [ [ int(x.split(',')[0]) , int(x.split(',')[1]) ] for x in p_h] )
x,y,w,h = cv2.boundingRect(textline_coords)
if self.dir_out_image_text:
if dir_out_image_text:
total_bb_coordinates.append([x,y,w,h])
h2w_ratio = h/float(w)
@ -5343,7 +5545,7 @@ class Eynollah_ocr:
img_crop = img_poly_on_img[y:y+h, x:x+w, :]
img_crop[mask_poly==0] = 255
self.logger.debug("processing %d lines for '%s'", len(cropped_lines), nn.attrib['id'])
if h2w_ratio > 0.1:
cropped_lines.append(resize_image(img_crop, tr_ocr_input_height_and_width, tr_ocr_input_height_and_width) )
cropped_lines_meging_indexing.append(0)
@ -5452,10 +5654,12 @@ class Eynollah_ocr:
unique_cropped_lines_region_indexer = np.unique(cropped_lines_region_indexer)
if self.dir_out_image_text:
if dir_out_image_text:
font_path = "Charis-7.000/Charis-Regular.ttf" # Make sure this file exists!
font = ImageFont.truetype(font_path, 40)
#font_path = "Charis-7.000/Charis-Regular.ttf" # Make sure this file exists!
font = importlib_resources.files(__package__) / "Charis-Regular.ttf"
with importlib_resources.as_file(font) as font:
font = ImageFont.truetype(font=font, size=40)
for indexer_text, bb_ind in enumerate(total_bb_coordinates):
@ -5465,7 +5669,7 @@ class Eynollah_ocr:
w_bb = bb_ind[2]
h_bb = bb_ind[3]
font = fit_text_single_line(draw, extracted_texts_merged[indexer_text], font_path, w_bb, int(h_bb*0.4) )
font = fit_text_single_line(draw, extracted_texts_merged[indexer_text], font.path, w_bb, int(h_bb*0.4) )
##draw.rectangle([x_bb, y_bb, x_bb + w_bb, y_bb + h_bb], outline="red", width=2)
@ -5580,18 +5784,10 @@ class Eynollah_ocr:
img_size=(image_width, image_height)
for ind_img in ls_imgs:
if self.dir_in:
file_name = Path(ind_img).stem
dir_img = os.path.join(self.dir_in, ind_img)
else:
file_name = Path(self.image_filename).stem
dir_img = self.image_filename
#file_name = Path(ind_img).stem
#dir_img = os.path.join(self.dir_in, ind_img)
dir_xml = os.path.join(self.dir_xmls, file_name+'.xml')
out_file_ocr = os.path.join(self.dir_out, file_name+'.xml')
for dir_img in ls_imgs:
file_name = Path(dir_img).stem
dir_xml = os.path.join(dir_xmls, file_name+'.xml')
out_file_ocr = os.path.join(dir_out, file_name+'.xml')
if os.path.exists(out_file_ocr):
if overwrite:
@ -5601,13 +5797,13 @@ class Eynollah_ocr:
continue
img = cv2.imread(dir_img)
if self.prediction_with_both_of_rgb_and_bin:
if dir_in_bin is not None:
cropped_lines_bin = []
dir_img_bin = os.path.join(self.dir_in_bin, file_name+'.png')
dir_img_bin = os.path.join(dir_in_bin, file_name+'.png')
img_bin = cv2.imread(dir_img_bin)
if self.dir_out_image_text:
out_image_with_text = os.path.join(self.dir_out_image_text, file_name+'.png')
if dir_out_image_text:
out_image_with_text = os.path.join(dir_out_image_text, file_name+'.png')
image_text = Image.new("RGB", (img.shape[1], img.shape[0]), "white")
draw = ImageDraw.Draw(image_text)
total_bb_coordinates = []
@ -5651,13 +5847,13 @@ class Eynollah_ocr:
if type_textregion=='drop-capital':
angle_degrees = 0
if self.dir_out_image_text:
if dir_out_image_text:
total_bb_coordinates.append([x,y,w,h])
w_scaled = w * image_height/float(h)
img_poly_on_img = np.copy(img)
if self.prediction_with_both_of_rgb_and_bin:
if dir_in_bin is not None:
img_poly_on_img_bin = np.copy(img_bin)
img_crop_bin = img_poly_on_img_bin[y:y+h, x:x+w, :]
@ -5680,7 +5876,7 @@ class Eynollah_ocr:
img_crop = rotate_image_with_padding(img_crop, better_des_slope )
if self.prediction_with_both_of_rgb_and_bin:
if dir_in_bin is not None:
img_crop_bin = rotate_image_with_padding(img_crop_bin, better_des_slope )
mask_poly = rotate_image_with_padding(mask_poly, better_des_slope )
@ -5695,13 +5891,13 @@ class Eynollah_ocr:
if not self.do_not_mask_with_textline_contour:
img_crop[mask_poly==0] = 255
if self.prediction_with_both_of_rgb_and_bin:
if dir_in_bin is not None:
img_crop_bin = img_crop_bin[y_n:y_n+h_n, x_n:x_n+w_n, :]
if not self.do_not_mask_with_textline_contour:
img_crop_bin[mask_poly==0] = 255
if mask_poly[:,:,0].sum() /float(w_n*h_n) < 0.50 and w_scaled > 90:
if self.prediction_with_both_of_rgb_and_bin:
if dir_in_bin is not None:
img_crop, img_crop_bin = break_curved_line_into_small_pieces_and_then_merge(img_crop, mask_poly, img_crop_bin)
else:
img_crop, _ = break_curved_line_into_small_pieces_and_then_merge(img_crop, mask_poly)
@ -5711,14 +5907,14 @@ class Eynollah_ocr:
better_des_slope = 0
if not self.do_not_mask_with_textline_contour:
img_crop[mask_poly==0] = 255
if self.prediction_with_both_of_rgb_and_bin:
if dir_in_bin is not None:
if not self.do_not_mask_with_textline_contour:
img_crop_bin[mask_poly==0] = 255
if type_textregion=='drop-capital':
pass
else:
if mask_poly[:,:,0].sum() /float(w*h) < 0.50 and w_scaled > 90:
if self.prediction_with_both_of_rgb_and_bin:
if dir_in_bin is not None:
img_crop, img_crop_bin = break_curved_line_into_small_pieces_and_then_merge(img_crop, mask_poly, img_crop_bin)
else:
img_crop, _ = break_curved_line_into_small_pieces_and_then_merge(img_crop, mask_poly)
@ -5733,14 +5929,12 @@ class Eynollah_ocr:
cropped_lines_ver_index.append(0)
cropped_lines_meging_indexing.append(0)
if self.prediction_with_both_of_rgb_and_bin:
if dir_in_bin is not None:
img_fin = preprocess_and_resize_image_for_ocrcnn_model(img_crop_bin, image_height, image_width)
cropped_lines_bin.append(img_fin)
else:
if self.prediction_with_both_of_rgb_and_bin:
splited_images, splited_images_bin = return_textlines_split_if_needed(img_crop, img_crop_bin, prediction_with_both_of_rgb_and_bin=self.prediction_with_both_of_rgb_and_bin)
else:
splited_images, splited_images_bin = return_textlines_split_if_needed(img_crop, None)
splited_images, splited_images_bin = return_textlines_split_if_needed(
img_crop, img_crop_bin if dir_in_bin is not None else None)
if splited_images:
img_fin = preprocess_and_resize_image_for_ocrcnn_model(splited_images[0], image_height, image_width)
cropped_lines.append(img_fin)
@ -5761,7 +5955,7 @@ class Eynollah_ocr:
else:
cropped_lines_ver_index.append(0)
if self.prediction_with_both_of_rgb_and_bin:
if dir_in_bin is not None:
img_fin = preprocess_and_resize_image_for_ocrcnn_model(splited_images_bin[0], image_height, image_width)
cropped_lines_bin.append(img_fin)
img_fin = preprocess_and_resize_image_for_ocrcnn_model(splited_images_bin[1], image_height, image_width)
@ -5777,7 +5971,7 @@ class Eynollah_ocr:
else:
cropped_lines_ver_index.append(0)
if self.prediction_with_both_of_rgb_and_bin:
if dir_in_bin is not None:
img_fin = preprocess_and_resize_image_for_ocrcnn_model(img_crop_bin, image_height, image_width)
cropped_lines_bin.append(img_fin)
@ -5790,29 +5984,15 @@ class Eynollah_ocr:
if cheild_text.tag.endswith("Unicode"):
textline_text = cheild_text.text
if textline_text:
if self.do_not_mask_with_textline_contour:
base_name = os.path.join(dir_out, file_name + '_line_' + str(indexer_textlines))
if self.pref_of_dataset:
with open(os.path.join(self.dir_out, file_name+'_line_'+str(indexer_textlines)+'_'+self.pref_of_dataset+'.txt'), 'w') as text_file:
base_name += '_' + self.pref_of_dataset
if not self.do_not_mask_with_textline_contour:
base_name += '_masked'
with open(base_name + '.txt', 'w') as text_file:
text_file.write(textline_text)
cv2.imwrite(os.path.join(self.dir_out, file_name+'_line_'+str(indexer_textlines)+'_'+self.pref_of_dataset+'.png'), img_crop )
else:
with open(os.path.join(self.dir_out, file_name+'_line_'+str(indexer_textlines)+'.txt'), 'w') as text_file:
text_file.write(textline_text)
cv2.imwrite(os.path.join(self.dir_out, file_name+'_line_'+str(indexer_textlines)+'.png'), img_crop )
else:
if self.pref_of_dataset:
with open(os.path.join(self.dir_out, file_name+'_line_'+str(indexer_textlines)+'_'+self.pref_of_dataset+'_masked.txt'), 'w') as text_file:
text_file.write(textline_text)
cv2.imwrite(os.path.join(self.dir_out, file_name+'_line_'+str(indexer_textlines)+'_'+self.pref_of_dataset+'_masked.png'), img_crop )
else:
with open(os.path.join(self.dir_out, file_name+'_line_'+str(indexer_textlines)+'_masked.txt'), 'w') as text_file:
text_file.write(textline_text)
cv2.imwrite(os.path.join(self.dir_out, file_name+'_line_'+str(indexer_textlines)+'_masked.png'), img_crop )
cv2.imwrite(base_name + '.png', img_crop)
indexer_textlines+=1
if not self.export_textline_images_and_text:
@ -5843,7 +6023,7 @@ class Eynollah_ocr:
else:
imgs_ver_flipped = None
if self.prediction_with_both_of_rgb_and_bin:
if dir_in_bin is not None:
imgs_bin = cropped_lines_bin[n_start:]
imgs_bin = np.array(imgs_bin)
imgs_bin = imgs_bin.reshape(imgs_bin.shape[0], image_height, image_width, 3)
@ -5873,7 +6053,7 @@ class Eynollah_ocr:
imgs_ver_flipped = None
if self.prediction_with_both_of_rgb_and_bin:
if dir_in_bin is not None:
imgs_bin = cropped_lines_bin[n_start:n_end]
imgs_bin = np.array(imgs_bin).reshape(self.b_s, image_height, image_width, 3)
@ -5886,6 +6066,7 @@ class Eynollah_ocr:
imgs_bin_ver_flipped = None
self.logger.debug("processing next %d lines", len(imgs))
preds = self.prediction_model.predict(imgs, verbose=0)
if len(indices_ver)>0:
@ -5912,7 +6093,7 @@ class Eynollah_ocr:
if len(indices_where_flipped_conf_value_is_higher)>0:
indices_to_be_replaced = indices_ver[indices_where_flipped_conf_value_is_higher]
preds[indices_to_be_replaced,:,:] = preds_flipped[indices_where_flipped_conf_value_is_higher, :, :]
if self.prediction_with_both_of_rgb_and_bin:
if dir_in_bin is not None:
preds_bin = self.prediction_model.predict(imgs_bin, verbose=0)
if len(indices_ver)>0:
@ -5959,7 +6140,7 @@ class Eynollah_ocr:
extracted_texts.append("")
extracted_conf_value.append(0)
del cropped_lines
if self.prediction_with_both_of_rgb_and_bin:
if dir_in_bin is not None:
del cropped_lines_bin
gc.collect()
@ -5972,10 +6153,12 @@ class Eynollah_ocr:
unique_cropped_lines_region_indexer = np.unique(cropped_lines_region_indexer)
if self.dir_out_image_text:
if dir_out_image_text:
font_path = "Charis-7.000/Charis-Regular.ttf" # Make sure this file exists!
font = ImageFont.truetype(font_path, 40)
#font_path = "Charis-7.000/Charis-Regular.ttf" # Make sure this file exists!
font = importlib_resources.files(__package__) / "Charis-Regular.ttf"
with importlib_resources.as_file(font) as font:
font = ImageFont.truetype(font=font, size=40)
for indexer_text, bb_ind in enumerate(total_bb_coordinates):
@ -5985,7 +6168,7 @@ class Eynollah_ocr:
w_bb = bb_ind[2]
h_bb = bb_ind[3]
font = fit_text_single_line(draw, extracted_texts_merged[indexer_text], font_path, w_bb, int(h_bb*0.4) )
font = fit_text_single_line(draw, extracted_texts_merged[indexer_text], font.path, w_bb, int(h_bb*0.4) )
##draw.rectangle([x_bb, y_bb, x_bb + w_bb, y_bb + h_bb], outline="red", width=2)

View file

@ -3,30 +3,24 @@ Image enhancer. The output can be written as same scale of input or in new predi
"""
from logging import Logger
from difflib import SequenceMatcher as sq
from PIL import Image, ImageDraw, ImageFont
import math
import os
import sys
import time
from typing import Optional
import atexit
import warnings
from functools import partial
from pathlib import Path
from multiprocessing import cpu_count
import gc
import copy
from loky import ProcessPoolExecutor
import xml.etree.ElementTree as ET
import cv2
import numpy as np
from ocrd import OcrdPage
from ocrd_utils import getLogger, tf_disable_interactive_logs
import statistics
import tensorflow as tf
from skimage.morphology import skeletonize
from tensorflow.keras.models import load_model
from .utils.resize import resize_image
from .utils.pil_cv2 import pil2cv
from .utils import (
is_image_filename,
crop_image_inside_box
)
@ -38,13 +32,11 @@ class Enhancer:
def __init__(
self,
dir_models : str,
dir_out : Optional[str] = None,
num_col_upper : Optional[int] = None,
num_col_lower : Optional[int] = None,
save_org_scale : bool = False,
logger : Optional[Logger] = None,
):
self.dir_out = dir_out
self.input_binary = False
self.light_version = False
self.save_org_scale = save_org_scale
@ -58,13 +50,11 @@ class Enhancer:
self.num_col_lower = num_col_lower
self.logger = logger if logger else getLogger('enhancement')
# for parallelization of CPU-intensive tasks:
self.executor = ProcessPoolExecutor(max_workers=cpu_count(), timeout=1200)
atexit.register(self.executor.shutdown)
self.dir_models = dir_models
self.model_dir_of_binarization = dir_models + "/eynollah-binarization_20210425"
self.model_dir_of_enhancement = dir_models + "/eynollah-enhancement_20210425"
self.model_dir_of_col_classifier = dir_models + "/eynollah-column-classifier_20210425"
self.model_page_dir = dir_models + "/eynollah-page-extraction_20210425"
self.model_page_dir = dir_models + "/model_eynollah_page_extraction_20250915"
try:
for device in tf.config.list_physical_devices('GPU'):
@ -75,10 +65,10 @@ class Enhancer:
self.model_page = self.our_load_model(self.model_page_dir)
self.model_classifier = self.our_load_model(self.model_dir_of_col_classifier)
self.model_enhancement = self.our_load_model(self.model_dir_of_enhancement)
self.model_bin = self.our_load_model(self.model_dir_of_binarization)
def cache_images(self, image_filename=None, image_pil=None, dpi=None):
ret = {}
t_c0 = time.time()
if image_filename:
ret['img'] = cv2.imread(image_filename)
if self.light_version:
@ -98,10 +88,9 @@ class Enhancer:
if dpi is not None:
self.dpi = dpi
def reset_file_name_dir(self, image_filename):
t_c = time.time()
def reset_file_name_dir(self, image_filename, dir_out):
self.cache_images(image_filename=image_filename)
self.output_filename = os.path.join(self.dir_out, Path(image_filename).stem +'.png')
self.output_filename = os.path.join(dir_out, Path(image_filename).stem +'.png')
def imread(self, grayscale=False, uint8=True):
key = 'img'
@ -699,7 +688,12 @@ class Enhancer:
return img_res
def run(self, image_filename : Optional[str] = None, dir_in : Optional[str] = None, overwrite : bool = False):
def run(self,
overwrite: bool = False,
image_filename: Optional[str] = None,
dir_in: Optional[str] = None,
dir_out: Optional[str] = None,
):
"""
Get image and scales, then extract the page of scanned image
"""
@ -707,17 +701,19 @@ class Enhancer:
t0_tot = time.time()
if dir_in:
self.ls_imgs = os.listdir(dir_in)
ls_imgs = [os.path.join(dir_in, image_filename)
for image_filename in filter(is_image_filename,
os.listdir(dir_in))]
elif image_filename:
self.ls_imgs = [image_filename]
ls_imgs = [image_filename]
else:
raise ValueError("run requires either a single image filename or a directory")
for img_filename in self.ls_imgs:
for img_filename in ls_imgs:
self.logger.info(img_filename)
t0 = time.time()
self.reset_file_name_dir(os.path.join(dir_in or "", img_filename))
self.reset_file_name_dir(img_filename, dir_out)
#print("text region early -11 in %.1fs", time.time() - t0)
if os.path.exists(self.output_filename):

View file

@ -3,48 +3,28 @@ Image enhancer. The output can be written as same scale of input or in new predi
"""
from logging import Logger
from difflib import SequenceMatcher as sq
from PIL import Image, ImageDraw, ImageFont
import math
import os
import sys
import time
from typing import Optional
import atexit
import warnings
from functools import partial
from pathlib import Path
from multiprocessing import cpu_count
import gc
import copy
from loky import ProcessPoolExecutor
import xml.etree.ElementTree as ET
import cv2
import numpy as np
from ocrd import OcrdPage
from ocrd_utils import getLogger, tf_disable_interactive_logs
from ocrd_utils import getLogger
import statistics
import tensorflow as tf
from tensorflow.keras.models import load_model
from .utils.resize import resize_image
from .utils import (
crop_image_inside_box
)
from .utils.contour import (
filter_contours_area_of_image,
filter_contours_area_of_image_tables,
find_contours_mean_y_diff,
find_new_features_of_contours,
find_features_of_contours,
get_text_region_boxes_by_given_contours,
get_textregion_contours_in_org_image,
get_textregion_contours_in_org_image_light,
return_contours_of_image,
return_contours_of_interested_region,
return_contours_of_interested_region_by_min_size,
return_contours_of_interested_textline,
return_parent_contours,
)
from .utils import is_xml_filename
DPI_THRESHOLD = 298
KERNEL = np.ones((5, 5), np.uint8)
@ -54,17 +34,11 @@ class machine_based_reading_order_on_layout:
def __init__(
self,
dir_models : str,
dir_out : Optional[str] = None,
logger : Optional[Logger] = None,
):
self.dir_out = dir_out
self.logger = logger if logger else getLogger('mbro on layout')
# for parallelization of CPU-intensive tasks:
self.executor = ProcessPoolExecutor(max_workers=cpu_count(), timeout=1200)
atexit.register(self.executor.shutdown)
self.logger = logger if logger else getLogger('mbreorder')
self.dir_models = dir_models
self.model_reading_order_dir = dir_models + "/model_eynollah_reading_order_20250824"#"/model_ens_reading_order_machine_based"
self.model_reading_order_dir = dir_models + "/model_eynollah_reading_order_20250824"
try:
for device in tf.config.list_physical_devices('GPU'):
@ -75,45 +49,6 @@ class machine_based_reading_order_on_layout:
self.model_reading_order = self.our_load_model(self.model_reading_order_dir)
self.light_version = True
def cache_images(self, image_filename=None, image_pil=None, dpi=None):
ret = {}
t_c0 = time.time()
if image_filename:
ret['img'] = cv2.imread(image_filename)
if self.light_version:
self.dpi = 100
else:
self.dpi = 0#check_dpi(image_filename)
else:
ret['img'] = pil2cv(image_pil)
if self.light_version:
self.dpi = 100
else:
self.dpi = 0#check_dpi(image_pil)
ret['img_grayscale'] = cv2.cvtColor(ret['img'], cv2.COLOR_BGR2GRAY)
for prefix in ('', '_grayscale'):
ret[f'img{prefix}_uint8'] = ret[f'img{prefix}'].astype(np.uint8)
self._imgs = ret
if dpi is not None:
self.dpi = dpi
def reset_file_name_dir(self, image_filename):
t_c = time.time()
self.cache_images(image_filename=image_filename)
self.output_filename = os.path.join(self.dir_out, Path(image_filename).stem +'.png')
def imread(self, grayscale=False, uint8=True):
key = 'img'
if grayscale:
key += '_grayscale'
if uint8:
key += '_uint8'
return self._imgs[key].copy()
def isNaN(self, num):
return num != num
@staticmethod
def our_load_model(model_file):
if model_file.endswith('.h5') and Path(model_file[:-3]).exists():
@ -126,280 +61,7 @@ class machine_based_reading_order_on_layout:
"PatchEncoder": PatchEncoder, "Patches": Patches})
return model
def predict_enhancement(self, img):
self.logger.debug("enter predict_enhancement")
img_height_model = self.model_enhancement.layers[-1].output_shape[1]
img_width_model = self.model_enhancement.layers[-1].output_shape[2]
if img.shape[0] < img_height_model:
img = cv2.resize(img, (img.shape[1], img_width_model), interpolation=cv2.INTER_NEAREST)
if img.shape[1] < img_width_model:
img = cv2.resize(img, (img_height_model, img.shape[0]), interpolation=cv2.INTER_NEAREST)
margin = int(0.1 * img_width_model)
width_mid = img_width_model - 2 * margin
height_mid = img_height_model - 2 * margin
img = img / 255.
img_h = img.shape[0]
img_w = img.shape[1]
prediction_true = np.zeros((img_h, img_w, 3))
nxf = img_w / float(width_mid)
nyf = img_h / float(height_mid)
nxf = int(nxf) + 1 if nxf > int(nxf) else int(nxf)
nyf = int(nyf) + 1 if nyf > int(nyf) else int(nyf)
for i in range(nxf):
for j in range(nyf):
if i == 0:
index_x_d = i * width_mid
index_x_u = index_x_d + img_width_model
else:
index_x_d = i * width_mid
index_x_u = index_x_d + img_width_model
if j == 0:
index_y_d = j * height_mid
index_y_u = index_y_d + img_height_model
else:
index_y_d = j * height_mid
index_y_u = index_y_d + img_height_model
if index_x_u > img_w:
index_x_u = img_w
index_x_d = img_w - img_width_model
if index_y_u > img_h:
index_y_u = img_h
index_y_d = img_h - img_height_model
img_patch = img[np.newaxis, index_y_d:index_y_u, index_x_d:index_x_u, :]
label_p_pred = self.model_enhancement.predict(img_patch, verbose=0)
seg = label_p_pred[0, :, :, :] * 255
if i == 0 and j == 0:
prediction_true[index_y_d + 0:index_y_u - margin,
index_x_d + 0:index_x_u - margin] = \
seg[0:-margin or None,
0:-margin or None]
elif i == nxf - 1 and j == nyf - 1:
prediction_true[index_y_d + margin:index_y_u - 0,
index_x_d + margin:index_x_u - 0] = \
seg[margin:,
margin:]
elif i == 0 and j == nyf - 1:
prediction_true[index_y_d + margin:index_y_u - 0,
index_x_d + 0:index_x_u - margin] = \
seg[margin:,
0:-margin or None]
elif i == nxf - 1 and j == 0:
prediction_true[index_y_d + 0:index_y_u - margin,
index_x_d + margin:index_x_u - 0] = \
seg[0:-margin or None,
margin:]
elif i == 0 and j != 0 and j != nyf - 1:
prediction_true[index_y_d + margin:index_y_u - margin,
index_x_d + 0:index_x_u - margin] = \
seg[margin:-margin or None,
0:-margin or None]
elif i == nxf - 1 and j != 0 and j != nyf - 1:
prediction_true[index_y_d + margin:index_y_u - margin,
index_x_d + margin:index_x_u - 0] = \
seg[margin:-margin or None,
margin:]
elif i != 0 and i != nxf - 1 and j == 0:
prediction_true[index_y_d + 0:index_y_u - margin,
index_x_d + margin:index_x_u - margin] = \
seg[0:-margin or None,
margin:-margin or None]
elif i != 0 and i != nxf - 1 and j == nyf - 1:
prediction_true[index_y_d + margin:index_y_u - 0,
index_x_d + margin:index_x_u - margin] = \
seg[margin:,
margin:-margin or None]
else:
prediction_true[index_y_d + margin:index_y_u - margin,
index_x_d + margin:index_x_u - margin] = \
seg[margin:-margin or None,
margin:-margin or None]
prediction_true = prediction_true.astype(int)
return prediction_true
def calculate_width_height_by_columns(self, img, num_col, width_early, label_p_pred):
self.logger.debug("enter calculate_width_height_by_columns")
if num_col == 1:
img_w_new = 2000
elif num_col == 2:
img_w_new = 2400
elif num_col == 3:
img_w_new = 3000
elif num_col == 4:
img_w_new = 4000
elif num_col == 5:
img_w_new = 5000
elif num_col == 6:
img_w_new = 6500
else:
img_w_new = width_early
img_h_new = img_w_new * img.shape[0] // img.shape[1]
if img_h_new >= 8000:
img_new = np.copy(img)
num_column_is_classified = False
else:
img_new = resize_image(img, img_h_new, img_w_new)
num_column_is_classified = True
return img_new, num_column_is_classified
def early_page_for_num_of_column_classification(self,img_bin):
self.logger.debug("enter early_page_for_num_of_column_classification")
if self.input_binary:
img = np.copy(img_bin).astype(np.uint8)
else:
img = self.imread()
img = cv2.GaussianBlur(img, (5, 5), 0)
img_page_prediction = self.do_prediction(False, img, self.model_page)
imgray = cv2.cvtColor(img_page_prediction, cv2.COLOR_BGR2GRAY)
_, thresh = cv2.threshold(imgray, 0, 255, 0)
thresh = cv2.dilate(thresh, KERNEL, iterations=3)
contours, _ = cv2.findContours(thresh, cv2.RETR_TREE, cv2.CHAIN_APPROX_SIMPLE)
if len(contours)>0:
cnt_size = np.array([cv2.contourArea(contours[j])
for j in range(len(contours))])
cnt = contours[np.argmax(cnt_size)]
box = cv2.boundingRect(cnt)
else:
box = [0, 0, img.shape[1], img.shape[0]]
cropped_page, page_coord = crop_image_inside_box(box, img)
self.logger.debug("exit early_page_for_num_of_column_classification")
return cropped_page, page_coord
def calculate_width_height_by_columns_1_2(self, img, num_col, width_early, label_p_pred):
self.logger.debug("enter calculate_width_height_by_columns")
if num_col == 1:
img_w_new = 1000
else:
img_w_new = 1300
img_h_new = img_w_new * img.shape[0] // img.shape[1]
if label_p_pred[0][int(num_col - 1)] < 0.9 and img_w_new < width_early:
img_new = np.copy(img)
num_column_is_classified = False
#elif label_p_pred[0][int(num_col - 1)] < 0.8 and img_h_new >= 8000:
elif img_h_new >= 8000:
img_new = np.copy(img)
num_column_is_classified = False
else:
img_new = resize_image(img, img_h_new, img_w_new)
num_column_is_classified = True
return img_new, num_column_is_classified
def resize_and_enhance_image_with_column_classifier(self, light_version):
self.logger.debug("enter resize_and_enhance_image_with_column_classifier")
dpi = 0#self.dpi
self.logger.info("Detected %s DPI", dpi)
if self.input_binary:
img = self.imread()
prediction_bin = self.do_prediction(True, img, self.model_bin, n_batch_inference=5)
prediction_bin = 255 * (prediction_bin[:,:,0]==0)
prediction_bin = np.repeat(prediction_bin[:, :, np.newaxis], 3, axis=2).astype(np.uint8)
img= np.copy(prediction_bin)
img_bin = prediction_bin
else:
img = self.imread()
self.h_org, self.w_org = img.shape[:2]
img_bin = None
width_early = img.shape[1]
t1 = time.time()
_, page_coord = self.early_page_for_num_of_column_classification(img_bin)
self.image_page_org_size = img[page_coord[0] : page_coord[1], page_coord[2] : page_coord[3], :]
self.page_coord = page_coord
if self.num_col_upper and not self.num_col_lower:
num_col = self.num_col_upper
label_p_pred = [np.ones(6)]
elif self.num_col_lower and not self.num_col_upper:
num_col = self.num_col_lower
label_p_pred = [np.ones(6)]
elif not self.num_col_upper and not self.num_col_lower:
if self.input_binary:
img_in = np.copy(img)
img_in = img_in / 255.0
img_in = cv2.resize(img_in, (448, 448), interpolation=cv2.INTER_NEAREST)
img_in = img_in.reshape(1, 448, 448, 3)
else:
img_1ch = self.imread(grayscale=True)
width_early = img_1ch.shape[1]
img_1ch = img_1ch[page_coord[0] : page_coord[1], page_coord[2] : page_coord[3]]
img_1ch = img_1ch / 255.0
img_1ch = cv2.resize(img_1ch, (448, 448), interpolation=cv2.INTER_NEAREST)
img_in = np.zeros((1, img_1ch.shape[0], img_1ch.shape[1], 3))
img_in[0, :, :, 0] = img_1ch[:, :]
img_in[0, :, :, 1] = img_1ch[:, :]
img_in[0, :, :, 2] = img_1ch[:, :]
label_p_pred = self.model_classifier.predict(img_in, verbose=0)
num_col = np.argmax(label_p_pred[0]) + 1
elif (self.num_col_upper and self.num_col_lower) and (self.num_col_upper!=self.num_col_lower):
if self.input_binary:
img_in = np.copy(img)
img_in = img_in / 255.0
img_in = cv2.resize(img_in, (448, 448), interpolation=cv2.INTER_NEAREST)
img_in = img_in.reshape(1, 448, 448, 3)
else:
img_1ch = self.imread(grayscale=True)
width_early = img_1ch.shape[1]
img_1ch = img_1ch[page_coord[0] : page_coord[1], page_coord[2] : page_coord[3]]
img_1ch = img_1ch / 255.0
img_1ch = cv2.resize(img_1ch, (448, 448), interpolation=cv2.INTER_NEAREST)
img_in = np.zeros((1, img_1ch.shape[0], img_1ch.shape[1], 3))
img_in[0, :, :, 0] = img_1ch[:, :]
img_in[0, :, :, 1] = img_1ch[:, :]
img_in[0, :, :, 2] = img_1ch[:, :]
label_p_pred = self.model_classifier.predict(img_in, verbose=0)
num_col = np.argmax(label_p_pred[0]) + 1
if num_col > self.num_col_upper:
num_col = self.num_col_upper
label_p_pred = [np.ones(6)]
if num_col < self.num_col_lower:
num_col = self.num_col_lower
label_p_pred = [np.ones(6)]
else:
num_col = self.num_col_upper
label_p_pred = [np.ones(6)]
self.logger.info("Found %d columns (%s)", num_col, np.around(label_p_pred, decimals=5))
if dpi < DPI_THRESHOLD:
if light_version and num_col in (1,2):
img_new, num_column_is_classified = self.calculate_width_height_by_columns_1_2(
img, num_col, width_early, label_p_pred)
else:
img_new, num_column_is_classified = self.calculate_width_height_by_columns(
img, num_col, width_early, label_p_pred)
if light_version:
image_res = np.copy(img_new)
else:
image_res = self.predict_enhancement(img_new)
is_image_enhanced = True
else:
num_column_is_classified = True
image_res = np.copy(img)
is_image_enhanced = False
self.logger.debug("exit resize_and_enhance_image_with_column_classifier")
return is_image_enhanced, img, image_res, num_col, num_column_is_classified, img_bin
def read_xml(self, xml_file):
file_name = Path(xml_file).stem
tree1 = ET.parse(xml_file, parser = ET.XMLParser(encoding='utf-8'))
root1=tree1.getroot()
alltags=[elem.tag for elem in root1.iter()]
@ -821,7 +483,7 @@ class machine_based_reading_order_on_layout:
img_poly=cv2.fillPoly(img, pts =co_img, color=(4,4,4))
img_poly=cv2.fillPoly(img, pts =co_sep, color=(5,5,5))
return tree1, root1, bb_coord_printspace, file_name, id_paragraph, id_header+id_heading, co_text_paragraph, co_text_header+co_text_heading,\
return tree1, root1, bb_coord_printspace, id_paragraph, id_header+id_heading, co_text_paragraph, co_text_header+co_text_heading,\
tot_region_ref,x_len, y_len,index_tot_regions, img_poly
def return_indexes_of_contours_loctaed_inside_another_list_of_contours(self, contours, contours_loc, cx_main_loc, cy_main_loc, indexes_loc):
@ -1070,7 +732,12 @@ class machine_based_reading_order_on_layout:
def run(self, xml_filename : Optional[str] = None, dir_in : Optional[str] = None, overwrite : bool = False):
def run(self,
overwrite: bool = False,
xml_filename: Optional[str] = None,
dir_in: Optional[str] = None,
dir_out: Optional[str] = None,
):
"""
Get image and scales, then extract the page of scanned image
"""
@ -1078,22 +745,22 @@ class machine_based_reading_order_on_layout:
t0_tot = time.time()
if dir_in:
self.ls_xmls = os.listdir(dir_in)
ls_xmls = [os.path.join(dir_in, xml_filename)
for xml_filename in filter(is_xml_filename,
os.listdir(dir_in))]
elif xml_filename:
self.ls_xmls = [xml_filename]
ls_xmls = [xml_filename]
else:
raise ValueError("run requires either a single image filename or a directory")
for xml_filename in self.ls_xmls:
for xml_filename in ls_xmls:
self.logger.info(xml_filename)
t0 = time.time()
if dir_in:
xml_file = os.path.join(dir_in, xml_filename)
else:
xml_file = xml_filename
tree_xml, root_xml, bb_coord_printspace, file_name, id_paragraph, id_header, co_text_paragraph, co_text_header, tot_region_ref, x_len, y_len, index_tot_regions, img_poly = self.read_xml(xml_file)
file_name = Path(xml_filename).stem
(tree_xml, root_xml, bb_coord_printspace, id_paragraph, id_header,
co_text_paragraph, co_text_header, tot_region_ref,
x_len, y_len, index_tot_regions, img_poly) = self.read_xml(xml_filename)
id_all_text = id_paragraph + id_header
@ -1136,7 +803,11 @@ class machine_based_reading_order_on_layout:
alltags=[elem.tag for elem in root_xml.iter()]
ET.register_namespace("",name_space)
tree_xml.write(os.path.join(self.dir_out, file_name+'.xml'),xml_declaration=True,method='xml',encoding="utf8",default_namespace=None)
tree_xml.write(os.path.join(dir_out, file_name+'.xml'),
xml_declaration=True,
method='xml',
encoding="utf8",
default_namespace=None)
#sys.exit()

View file

@ -1,5 +1,5 @@
{
"version": "0.4.0",
"version": "0.5.0",
"git_url": "https://github.com/qurator-spk/eynollah",
"dockerhub": "ocrd/eynollah",
"tools": {
@ -82,13 +82,23 @@
}
},
"resources": [
{
"url": "https://zenodo.org/records/17194824/files/models_layout_v0_5_0.tar.gz?download=1",
"name": "models_layout_v0_5_0",
"type": "archive",
"path_in_archive": "models_layout_v0_5_0",
"size": 3525684179,
"description": "Models for layout detection, reading order detection, textline detection, page extraction, column classification, table detection, binarization, image enhancement",
"version_range": ">= v0.5.0"
},
{
"description": "models for eynollah (TensorFlow SavedModel format)",
"url": "https://github.com/qurator-spk/eynollah/releases/download/v0.3.1/models_eynollah.tar.gz",
"name": "default",
"size": 1894627041,
"type": "archive",
"path_in_archive": "models_eynollah"
"path_in_archive": "models_eynollah",
"version_range": ">= v0.3.0, < v0.5.0"
}
]
},

View file

@ -1,6 +1,7 @@
from functools import cached_property
from typing import Optional
from ocrd_models import OcrdPage
from ocrd import Processor, OcrdPageResult
from ocrd import OcrdPageResultImage, Processor, OcrdPageResult
from .eynollah import Eynollah, EynollahXmlWriter
@ -9,8 +10,8 @@ class EynollahProcessor(Processor):
# already employs GPU (without singleton process atm)
max_workers = 1
@property
def executable(self):
@cached_property
def executable(self) -> str:
return 'ocrd-eynollah-segment'
def setup(self) -> None:
@ -20,7 +21,6 @@ class EynollahProcessor(Processor):
"and parameter 'light_version' (faster+simpler method for main region detection and deskewing)")
self.eynollah = Eynollah(
self.resolve_resource(self.parameter['models']),
logger=self.logger,
allow_enhancement=self.parameter['allow_enhancement'],
curved_line=self.parameter['curved_line'],
right2left=self.parameter['right_to_left'],
@ -33,6 +33,7 @@ class EynollahProcessor(Processor):
headers_off=self.parameter['headers_off'],
tables=self.parameter['tables'],
)
self.eynollah.logger = self.logger
self.eynollah.plotter = None
def shutdown(self):

View file

@ -16,6 +16,7 @@ import tensorflow as tf
from tensorflow.keras.models import load_model
from tensorflow.python.keras import backend as tensorflow_backend
from .utils import is_image_filename
def resize_image(img_in, input_height, input_width):
return cv2.resize(img_in, (input_width, input_height), interpolation=cv2.INTER_NEAREST)
@ -314,8 +315,8 @@ class SbbBinarizer:
prediction_true = prediction_true.astype(np.uint8)
return prediction_true[:,:,0]
def run(self, image=None, image_path=None, save=None, use_patches=False, dir_in=None, dir_out=None):
print(dir_in,'dir_in')
def run(self, image=None, image_path=None, output=None, use_patches=False, dir_in=None):
# print(dir_in,'dir_in')
if not dir_in:
if (image is not None and image_path is not None) or \
(image is None and image_path is None):
@ -343,11 +344,11 @@ class SbbBinarizer:
kernel = np.ones((5, 5), np.uint8)
img_last[:, :][img_last[:, :] > 0] = 255
img_last = (img_last[:, :] == 0) * 255
if save:
cv2.imwrite(save, img_last)
if output:
cv2.imwrite(output, img_last)
return img_last
else:
ls_imgs = os.listdir(dir_in)
ls_imgs = list(filter(is_image_filename, os.listdir(dir_in)))
for image_name in ls_imgs:
image_stem = image_name.split('.')[0]
print(image_name,'image_name')
@ -374,4 +375,4 @@ class SbbBinarizer:
img_last[:, :][img_last[:, :] > 0] = 255
img_last = (img_last[:, :] == 0) * 255
cv2.imwrite(os.path.join(dir_out,image_stem+'.png'), img_last)
cv2.imwrite(os.path.join(output, image_stem + '.png'), img_last)

View file

@ -2194,3 +2194,14 @@ def return_boxes_of_images_by_order_of_reading_new(
return boxes, peaks_neg_tot_tables_new
else:
return boxes, peaks_neg_tot_tables
def is_image_filename(fname: str) -> bool:
return fname.lower().endswith(('.jpg',
'.jpeg',
'.png',
'.tif',
'.tiff',
))
def is_xml_filename(fname: str) -> bool:
return fname.lower().endswith('.xml')

View file

@ -109,13 +109,13 @@ def fit_text_single_line(draw, text, font_path, max_width, max_height):
return ImageFont.truetype(font_path, 10) # Smallest font fallback
def return_textlines_split_if_needed(textline_image, textline_image_bin, prediction_with_both_of_rgb_and_bin=False):
def return_textlines_split_if_needed(textline_image, textline_image_bin=None):
split_point = return_start_and_end_of_common_text_of_textline_ocr_without_common_section(textline_image)
if split_point:
image1 = textline_image[:, :split_point,:]# image.crop((0, 0, width2, height))
image2 = textline_image[:, split_point:,:]#image.crop((width1, 0, width, height))
if prediction_with_both_of_rgb_and_bin:
if textline_image_bin is not None:
image1_bin = textline_image_bin[:, :split_point,:]# image.crop((0, 0, width2, height))
image2_bin = textline_image_bin[:, split_point:,:]#image.crop((width1, 0, width, height))
return [image1, image2], [image1_bin, image2_bin]

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1,22 +1,30 @@
from os import environ
from pathlib import Path
import pytest
import logging
from PIL import Image
from eynollah.cli import layout as layout_cli, binarization as binarization_cli
from eynollah.cli import (
layout as layout_cli,
binarization as binarization_cli,
enhancement as enhancement_cli,
machine_based_reading_order as mbreorder_cli,
ocr as ocr_cli,
)
from click.testing import CliRunner
from ocrd_modelfactory import page_from_file
from ocrd_models.constants import NAMESPACES as NS
testdir = Path(__file__).parent.resolve()
EYNOLLAH_MODELS = environ.get('EYNOLLAH_MODELS', str(testdir.joinpath('..', 'models_eynollah').resolve()))
SBBBIN_MODELS = environ.get('SBBBIN_MODELS', str(testdir.joinpath('..', 'default-2021-03-09').resolve()))
MODELS_LAYOUT = environ.get('MODELS_LAYOUT', str(testdir.joinpath('..', 'models_layout_v0_5_0').resolve()))
MODELS_OCR = environ.get('MODELS_OCR', str(testdir.joinpath('..', 'models_ocr_v0_5_0').resolve()))
MODELS_BIN = environ.get('MODELS_BIN', str(testdir.joinpath('..', 'default-2021-03-09').resolve()))
def test_run_eynollah_layout_filename(tmp_path, subtests, pytestconfig, caplog):
infile = testdir.joinpath('resources/kant_aufklaerung_1784_0020.tif')
outfile = tmp_path / 'kant_aufklaerung_1784_0020.xml'
args = [
'-m', EYNOLLAH_MODELS,
'-m', MODELS_LAYOUT,
'-i', str(infile),
'-o', str(outfile.parent),
# subtests write to same location
@ -44,8 +52,7 @@ def test_run_eynollah_layout_filename(tmp_path, subtests, pytestconfig, caplog):
options=options):
with caplog.filtering(only_eynollah):
result = runner.invoke(layout_cli, args + options, catch_exceptions=False)
print(result)
assert result.exit_code == 0
assert result.exit_code == 0, result.stdout
logmsgs = [logrec.message for logrec in caplog.records]
assert str(infile) in logmsgs
assert outfile.exists()
@ -61,7 +68,7 @@ def test_run_eynollah_layout_directory(tmp_path, pytestconfig, caplog):
indir = testdir.joinpath('resources')
outdir = tmp_path
args = [
'-m', EYNOLLAH_MODELS,
'-m', MODELS_LAYOUT,
'-di', str(indir),
'-o', str(outdir),
]
@ -72,9 +79,8 @@ def test_run_eynollah_layout_directory(tmp_path, pytestconfig, caplog):
return logrec.name == 'eynollah'
runner = CliRunner()
with caplog.filtering(only_eynollah):
result = runner.invoke(layout_cli, args)
print(result)
assert result.exit_code == 0
result = runner.invoke(layout_cli, args, catch_exceptions=False)
assert result.exit_code == 0, result.stdout
logmsgs = [logrec.message for logrec in caplog.records]
assert len([logmsg for logmsg in logmsgs if logmsg.startswith('Job done in')]) == 2
assert any(logmsg for logmsg in logmsgs if logmsg.startswith('All jobs done in'))
@ -84,10 +90,12 @@ def test_run_eynollah_binarization_filename(tmp_path, subtests, pytestconfig, ca
infile = testdir.joinpath('resources/kant_aufklaerung_1784_0020.tif')
outfile = tmp_path.joinpath('kant_aufklaerung_1784_0020.png')
args = [
'-m', SBBBIN_MODELS,
str(infile),
str(outfile),
'-m', MODELS_BIN,
'-i', str(infile),
'-o', str(outfile),
]
if pytestconfig.getoption('verbose') > 0:
args.extend(['-l', 'DEBUG'])
caplog.set_level(logging.INFO)
def only_eynollah(logrec):
return logrec.name == 'SbbBinarizer'
@ -99,9 +107,8 @@ def test_run_eynollah_binarization_filename(tmp_path, subtests, pytestconfig, ca
with subtests.test(#msg="test CLI",
options=options):
with caplog.filtering(only_eynollah):
result = runner.invoke(binarization_cli, args + options)
print(result)
assert result.exit_code == 0
result = runner.invoke(binarization_cli, args + options, catch_exceptions=False)
assert result.exit_code == 0, result.stdout
logmsgs = [logrec.message for logrec in caplog.records]
assert any(True for logmsg in logmsgs if logmsg.startswith('Predicting'))
assert outfile.exists()
@ -115,18 +122,193 @@ def test_run_eynollah_binarization_directory(tmp_path, subtests, pytestconfig, c
indir = testdir.joinpath('resources')
outdir = tmp_path
args = [
'-m', SBBBIN_MODELS,
'-m', MODELS_BIN,
'-di', str(indir),
'-do', str(outdir),
'-o', str(outdir),
]
if pytestconfig.getoption('verbose') > 0:
args.extend(['-l', 'DEBUG'])
caplog.set_level(logging.INFO)
def only_eynollah(logrec):
return logrec.name == 'SbbBinarizer'
runner = CliRunner()
with caplog.filtering(only_eynollah):
result = runner.invoke(binarization_cli, args)
print(result)
assert result.exit_code == 0
result = runner.invoke(binarization_cli, args, catch_exceptions=False)
assert result.exit_code == 0, result.stdout
logmsgs = [logrec.message for logrec in caplog.records]
assert len([logmsg for logmsg in logmsgs if logmsg.startswith('Predicting')]) == 2
assert len(list(outdir.iterdir())) == 2
def test_run_eynollah_enhancement_filename(tmp_path, subtests, pytestconfig, caplog):
infile = testdir.joinpath('resources/kant_aufklaerung_1784_0020.tif')
outfile = tmp_path.joinpath('kant_aufklaerung_1784_0020.png')
args = [
'-m', MODELS_LAYOUT,
'-i', str(infile),
'-o', str(outfile.parent),
# subtests write to same location
'--overwrite',
]
if pytestconfig.getoption('verbose') > 0:
args.extend(['-l', 'DEBUG'])
caplog.set_level(logging.INFO)
def only_eynollah(logrec):
return logrec.name == 'enhancement'
runner = CliRunner()
for options in [
[], # defaults
["-sos"],
]:
with subtests.test(#msg="test CLI",
options=options):
with caplog.filtering(only_eynollah):
result = runner.invoke(enhancement_cli, args + options, catch_exceptions=False)
assert result.exit_code == 0, result.stdout
logmsgs = [logrec.message for logrec in caplog.records]
assert any(True for logmsg in logmsgs if logmsg.startswith('Image was enhanced')), logmsgs
assert outfile.exists()
with Image.open(infile) as original_img:
original_size = original_img.size
with Image.open(outfile) as enhanced_img:
enhanced_size = enhanced_img.size
assert (original_size == enhanced_size) == ("-sos" in options)
def test_run_eynollah_enhancement_directory(tmp_path, subtests, pytestconfig, caplog):
indir = testdir.joinpath('resources')
outdir = tmp_path
args = [
'-m', MODELS_LAYOUT,
'-di', str(indir),
'-o', str(outdir),
]
if pytestconfig.getoption('verbose') > 0:
args.extend(['-l', 'DEBUG'])
caplog.set_level(logging.INFO)
def only_eynollah(logrec):
return logrec.name == 'enhancement'
runner = CliRunner()
with caplog.filtering(only_eynollah):
result = runner.invoke(enhancement_cli, args, catch_exceptions=False)
assert result.exit_code == 0, result.stdout
logmsgs = [logrec.message for logrec in caplog.records]
assert len([logmsg for logmsg in logmsgs if logmsg.startswith('Image was enhanced')]) == 2
assert len(list(outdir.iterdir())) == 2
def test_run_eynollah_mbreorder_filename(tmp_path, subtests, pytestconfig, caplog):
infile = testdir.joinpath('resources/kant_aufklaerung_1784_0020.xml')
outfile = tmp_path.joinpath('kant_aufklaerung_1784_0020.xml')
args = [
'-m', MODELS_LAYOUT,
'-i', str(infile),
'-o', str(outfile.parent),
]
if pytestconfig.getoption('verbose') > 0:
args.extend(['-l', 'DEBUG'])
caplog.set_level(logging.INFO)
def only_eynollah(logrec):
return logrec.name == 'mbreorder'
runner = CliRunner()
with caplog.filtering(only_eynollah):
result = runner.invoke(mbreorder_cli, args, catch_exceptions=False)
assert result.exit_code == 0, result.stdout
logmsgs = [logrec.message for logrec in caplog.records]
# FIXME: mbreorder has no logging!
#assert any(True for logmsg in logmsgs if logmsg.startswith('???')), logmsgs
assert outfile.exists()
#in_tree = page_from_file(str(infile)).etree
#in_order = in_tree.xpath("//page:OrderedGroup//@regionRef", namespaces=NS)
out_tree = page_from_file(str(outfile)).etree
out_order = out_tree.xpath("//page:OrderedGroup//@regionRef", namespaces=NS)
#assert len(out_order) >= 2, "result is inaccurate"
#assert in_order != out_order
assert out_order == ['r_1_1', 'r_2_1', 'r_2_2', 'r_2_3']
def test_run_eynollah_mbreorder_directory(tmp_path, subtests, pytestconfig, caplog):
indir = testdir.joinpath('resources')
outdir = tmp_path
args = [
'-m', MODELS_LAYOUT,
'-di', str(indir),
'-o', str(outdir),
]
if pytestconfig.getoption('verbose') > 0:
args.extend(['-l', 'DEBUG'])
caplog.set_level(logging.INFO)
def only_eynollah(logrec):
return logrec.name == 'mbreorder'
runner = CliRunner()
with caplog.filtering(only_eynollah):
result = runner.invoke(mbreorder_cli, args, catch_exceptions=False)
assert result.exit_code == 0, result.stdout
logmsgs = [logrec.message for logrec in caplog.records]
# FIXME: mbreorder has no logging!
#assert len([logmsg for logmsg in logmsgs if logmsg.startswith('???')]) == 2
assert len(list(outdir.iterdir())) == 2
def test_run_eynollah_ocr_filename(tmp_path, subtests, pytestconfig, caplog):
infile = testdir.joinpath('resources/kant_aufklaerung_1784_0020.tif')
outfile = tmp_path.joinpath('kant_aufklaerung_1784_0020.xml')
outrenderfile = tmp_path.joinpath('render').joinpath('kant_aufklaerung_1784_0020.png')
outrenderfile.parent.mkdir()
args = [
'-m', MODELS_OCR,
'-i', str(infile),
'-dx', str(infile.parent),
'-o', str(outfile.parent),
# subtests write to same location
'--overwrite',
]
if pytestconfig.getoption('verbose') > 0:
args.extend(['-l', 'DEBUG'])
caplog.set_level(logging.DEBUG)
def only_eynollah(logrec):
return logrec.name == 'eynollah'
runner = CliRunner()
for options in [
# kba Fri Sep 26 12:53:49 CEST 2025
# Disabled until NHWC/NCHW error in https://github.com/qurator-spk/eynollah/actions/runs/18019655200/job/51273541895 debugged
# [], # defaults
# ["-doit", str(outrenderfile.parent)],
["-trocr"],
]:
with subtests.test(#msg="test CLI",
options=options):
with caplog.filtering(only_eynollah):
result = runner.invoke(ocr_cli, args + options, catch_exceptions=False)
assert result.exit_code == 0, result.stdout
logmsgs = [logrec.message for logrec in caplog.records]
# FIXME: ocr has no logging!
#assert any(True for logmsg in logmsgs if logmsg.startswith('???')), logmsgs
assert outfile.exists()
if "-doit" in options:
assert outrenderfile.exists()
#in_tree = page_from_file(str(infile)).etree
#in_order = in_tree.xpath("//page:OrderedGroup//@regionRef", namespaces=NS)
out_tree = page_from_file(str(outfile)).etree
out_texts = out_tree.xpath("//page:TextLine/page:TextEquiv[last()]/page:Unicode/text()", namespaces=NS)
assert len(out_texts) >= 2, ("result is inaccurate", out_texts)
assert sum(map(len, out_texts)) > 100, ("result is inaccurate", out_texts)
@pytest.mark.skip("Disabled until NHWC/NCHW error in https://github.com/qurator-spk/eynollah/actions/runs/18019655200/job/51273541895 debugged")
def test_run_eynollah_ocr_directory(tmp_path, subtests, pytestconfig, caplog):
indir = testdir.joinpath('resources')
outdir = tmp_path
args = [
'-m', MODELS_OCR,
'-di', str(indir),
'-dx', str(indir),
'-o', str(outdir),
]
if pytestconfig.getoption('verbose') > 0:
args.extend(['-l', 'DEBUG'])
caplog.set_level(logging.INFO)
def only_eynollah(logrec):
return logrec.name == 'eynollah'
runner = CliRunner()
with caplog.filtering(only_eynollah):
result = runner.invoke(ocr_cli, args, catch_exceptions=False)
assert result.exit_code == 0, result.stdout
logmsgs = [logrec.message for logrec in caplog.records]
# FIXME: ocr has no logging!
#assert any(True for logmsg in logmsgs if logmsg.startswith('???')), logmsgs
assert len(list(outdir.iterdir())) == 2