Repository: OCR-D/ocrd_tesserocr (language: python)

Issue:
Use ocrd-tesserocr with caching for Processing Server not possible
In the [processing server pull request](https://github.com/OCR-D/core/pull/974) we want to use caching. Therefore an [instance of a processor without a workspace](https://github.com/OCR-D/core/blob/fd5effb967bad1f2ff860e3f5efd41997c77003d/ocrd/ocrd/processor/helpers.py#L283-L299
) is created. Later when the user requests a processor-run we want to [set the workspace into that cache-instance](https://github.com/OCR-D/core/blob/fd5effb967bad1f2ff860e3f5efd41997c77003d/ocrd/ocrd/processor/helpers.py#L313-L318).

The problem seems to be that ocrd-tesserocr processors delegates the processing internally to the processor TesserocrRecognize`:
https://github.com/OCR-D/ocrd_tesserocr/blob/c10f94d2b36815314687a216495e8602b4a16bb0/ocrd_tesserocr/segment_region.py#L13-L32

The workspace is transferred to this class on creation. If the workspace is (re-)set afterwards it does not get delegated to the created instance of `TesserocrRecognize`


Discussion / hints:
Indeed, this pattern is a problem.

> if hasattr(self, 'workspace'):

We could easily rewrite this by testing for `output_file_grp` instead of `workspace` AFAICS. (It's just there to differentiate between the other, non-processing CLI contexts, like dump and help.)
The only solution I see is making `workspace` a property in the processor-base-class. And then override the setter in ocrd_tesserorc and transfer the newly set workspace to the wrapped instance of TesserocrSegmentRegion as well. But I don't know if that can be done in ocr-d-core without problems.
> The only solution I see is making `workspace` a property in the processor-base-class. And then override the setter in ocrd_tesserorc and transfer the newly set workspace to the wrapped instance of TesserocrSegmentRegion as well.

I don't understand how that would help. The kwarg transfer needs to happen in the constructor already.

Within the new paradigm of the [changed processor API](https://github.com/OCR-D/core/issues/322#issuecomment-700991484), differentiating between processing and non-processing context becomes painless. (The former can happen in `setup`, including all the parameter kwarg transfers.)

But for the moment, as I said, exchanging `workspace` for `output_file_grp` test should be safe. (And of course, passing `*args` indiscriminately, not `self.workspace` explicitly.)
> I don't understand how that would help. The kwarg transfer needs to happen in the constructor already.

That's the problem I am trying to describe. At the constructor we don't have the workspace yet because we are creating a cached instance. We want to set the workspace later after the constructor has finished. But when we do that (setting the workspace) the wrapped instance of `TesserocrRecognize` does not get updated with the "new" workspace.

With the code snipped (ocrd_tesserocr/ocrd_tesserocr/segment_region.py) I only wanted to show that the processor wraps an instance of TesserocrRecognize which does the real work. The `if hasattr(self, 'workspace')` is not a problem when the workspace is set to `None` with the first *positional* argument.
> But when we do that (setting the workspace) the wrapped instance of `TesserocrRecognize` does not get updated with the "new" workspace.

Ah, sry, I didn't see it! Yes, since we don't override the constructor, but construct and then wrap a separate processor object, many patterns don't work. So I would say this trick is bad practice and should be ruled out in the future.

IIRC I used that wrap pattern instead of overriding the constructor because I did not want the user to see the processor appear like ocrd-tesserocr-recognize (with all its complicated parameters). But the downsides are obviously too great. 

I'll make a PR based on the simpler pattern.

> The only solution I see is making `workspace` a property in the processor-base-class. And then override the setter in ocrd_tesserorc and transfer the newly set workspace to the wrapped instance of TesserocrSegmentRegion as well.

Yes, that would work, and maybe it's also the best pattern in general going forward for all processors. (The setter could do many other things, like syncing/closing log files, releasing locks, syncing the METS cache or METS server.)

Fix patch:
diff --git a/.circleci/config.yml b/.circleci/config.yml
index fb5a414..5afab93 100644
--- a/.circleci/config.yml
+++ b/.circleci/config.yml
@@ -25,6 +25,7 @@ jobs:
       # PPA tessdata prefix (= ocrd_tesserocr moduledir) is owned by root
       - run: sudo chmod go+w `dpkg-query -L tesseract-ocr-eng | sed -n s,/eng.traineddata,,p`
       - run: ocrd resmgr download ocrd-tesserocr-recognize deu.traineddata
+      - run: ocrd resmgr download ocrd-tesserocr-recognize Fraktur.traineddata
       - run: make test-cli
       - run: make coverage
       - codecov/upload
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 49a1e1d..bd26101 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,22 @@ Versioned according to [Semantic Versioning](http://semver.org/).
 
 ## Unreleased
 
+Fixed:
+
+ * segment/recognize: fix `shrink_polygons`
+ * segment/recognize: fix reinit scope (for `xpath_model` and `auto_model`)
+ * CI: test multiple Python versions independent of ocrd/core image
+ * CI: speed up build for EOL Python 3.6
+ * CI: chmod o+w tessdata directory of PPA/OS Tesseract
+ * deps-ubuntu: allow installation of PPA Tesseract to fail (for newer OS)
+
+Changed:
+
+ * adapted to Shapely v2
+ * *: inherit from recognize (but override logger)
+ * segment*: delegate constructor instead of wrapping instance
+ * requires ocrd==2.48
+
 ## [0.16.0] - 2022-10-25
 
 Changed:
diff --git a/Makefile b/Makefile
index 2880f80..e1648ae 100644
--- a/Makefile
+++ b/Makefile
@@ -66,6 +66,7 @@ deps-ubuntu:
 		libtesseract-dev \
 		libleptonica-dev \
 		tesseract-ocr-eng \
+		tesseract-ocr-script-frak \
 		tesseract-ocr
 
 # Install Python deps for install via pip
@@ -90,11 +91,11 @@ install: deps
 
 # Run unit tests
 test: test/assets deps-test
-	# declare -p HTTP_PROXY
-	#$(PYTHON) -m pytest --continue-on-collection-errors test $(PYTEST_ARGS)
-	# workaround for missing module-init separation: run separately
+	@# declare -p HTTP_PROXY
+	#$(PYTHON) -m pytest -n auto --continue-on-collection-errors test $(PYTEST_ARGS)
+	# workaround for pytest-xdist not isolating setenv calls in click.CliRunner from each other:
 	$(PYTHON) -m pytest --continue-on-collection-errors test/test_cli.py $(PYTEST_ARGS)
-	$(PYTHON) -m pytest --continue-on-collection-errors test/test_{recognize,segment_{region,line,word}}.py $(PYTEST_ARGS)
+	$(PYTHON) -m pytest -n auto --continue-on-collection-errors test/test_{segment_{region,line,word},recognize}.py $(PYTEST_ARGS)
 
 # Run unit tests and determine test coverage
 coverage: deps-test
diff --git a/ocrd_tesserocr/binarize.py b/ocrd_tesserocr/binarize.py
index a61716b..84a5e5e 100644
--- a/ocrd_tesserocr/binarize.py
+++ b/ocrd_tesserocr/binarize.py
@@ -18,18 +18,18 @@
     TextRegionType,
     to_xml
 )
-from ocrd import Processor
 
 from .config import OCRD_TOOL
+from .recognize import TesserocrRecognize
 
 TOOL = 'ocrd-tesserocr-binarize'
 
-class TesserocrBinarize(Processor):
-
+class TesserocrBinarize(TesserocrRecognize):
     def __init__(self, *args, **kwargs):
-        kwargs['ocrd_tool'] = OCRD_TOOL['tools'][TOOL]
-        kwargs['version'] = OCRD_TOOL['version']
-        super(TesserocrBinarize, self).__init__(*args, **kwargs)
+        kwargs.setdefault('ocrd_tool', OCRD_TOOL['tools'][TOOL])
+        super().__init__(*args, **kwargs)
+        if hasattr(self, 'parameter'):
+            self.logger = getLogger('processor.TesserocrBinarize')
 
     def process(self):
         """Performs binarization of the region / line with Tesseract on the workspace.
@@ -47,7 +47,6 @@ def process(self):
         
         Produce a new output file by serialising the resulting hierarchy.
         """
-        LOG = getLogger('processor.TesserocrBinarize')
         assert_file_grp_cardinality(self.input_file_grp, 1)
         assert_file_grp_cardinality(self.output_file_grp, 1)
 
@@ -58,7 +57,7 @@ def process(self):
             for n, input_file in enumerate(self.input_files):
                 file_id = make_file_id(input_file, self.output_file_grp)
                 page_id = input_file.pageId or input_file.ID
-                LOG.info("INPUT FILE %i / %s", n, page_id)
+                self.logger.info("INPUT FILE %i / %s", n, page_id)
                 pcgts = page_from_file(self.workspace.download_file(input_file))
                 self.add_metadata(pcgts)
                 page = pcgts.get_Page()
@@ -66,17 +65,17 @@ def process(self):
                     page, page_id)
                 if self.parameter['dpi'] > 0:
                     dpi = self.parameter['dpi']
-                    LOG.info("Page '%s' images will use %d DPI from parameter override", page_id, dpi)
+                    self.logger.info("Page '%s' images will use %d DPI from parameter override", page_id, dpi)
                 elif page_image_info.resolution != 1:
                     dpi = page_image_info.resolution
                     if page_image_info.resolutionUnit == 'cm':
                         dpi = round(dpi * 2.54)
-                    LOG.info("Page '%s' images will use %d DPI from image meta-data", page_id, dpi)
+                    self.logger.info("Page '%s' images will use %d DPI from image meta-data", page_id, dpi)
                 else:
                     dpi = 0
-                    LOG.info("Page '%s' images will use DPI estimated from segmentation", page_id)
+                    self.logger.info("Page '%s' images will use DPI estimated from segmentation", page_id)
                 tessapi.SetVariable('user_defined_dpi', str(dpi))
-                LOG.info("Binarizing on '%s' level in page '%s'", oplevel, page_id)
+                self.logger.info("Binarizing on '%s' level in page '%s'", oplevel, page_id)
 
                 if oplevel == 'page':
                     tessapi.SetPageSegMode(PSM.AUTO_ONLY)
@@ -99,11 +98,11 @@ def process(self):
                         page.add_AlternativeImage(AlternativeImageType(
                             filename=file_path, comments=features))
                     else:
-                        LOG.error('Cannot binarize %s', "page '%s'" % page_id)
+                        self.logger.error('Cannot binarize %s', "page '%s'" % page_id)
                 else:
                     regions = page.get_TextRegion() + page.get_TableRegion()
                     if not regions:
-                        LOG.warning("Page '%s' contains no text regions", page_id)
+                        self.logger.warning("Page '%s' contains no text regions", page_id)
                     for region in regions:
                         region_image, region_xywh = self.workspace.image_from_segment(
                             region, page_image, page_xywh)
@@ -115,8 +114,8 @@ def process(self):
                         elif isinstance(region, TextRegionType):
                             lines = region.get_TextLine()
                             if not lines:
-                                LOG.warning("Page '%s' region '%s' contains no text lines",
-                                            page_id, region.id)
+                                self.logger.warning("Page '%s' region '%s' contains no text lines",
+                                                    page_id, region.id)
                             for line in lines:
                                 line_image, line_xywh = self.workspace.image_from_segment(
                                     line, region_image, region_xywh)
@@ -128,23 +127,22 @@ def process(self):
                 file_id = make_file_id(input_file, self.output_file_grp)
                 pcgts.set_pcGtsId(file_id)
                 self.workspace.add_file(
-                    ID=file_id,
+                    file_id=file_id,
                     file_grp=self.output_file_grp,
-                    pageId=input_file.pageId,
+                    page_id=input_file.pageId,
                     mimetype=MIMETYPE_PAGE,
                     local_filename=os.path.join(self.output_file_grp,
                                                 file_id + '.xml'),
                     content=to_xml(pcgts))
 
     def _process_segment(self, tessapi, ril, segment, image, xywh, where, page_id, file_id):
-        LOG = getLogger('processor.TesserocrBinarize')
         tessapi.SetImage(image)
         image_bin = None
         layout = tessapi.AnalyseLayout()
         if layout:
             image_bin = layout.GetBinaryImage(ril)
         if not image_bin:
-            LOG.error('Cannot binarize %s', where)
+            self.logger.error('Cannot binarize %s', where)
             return
         # update METS (add the image file):
         file_path = self.workspace.save_image_file(image_bin,
diff --git a/ocrd_tesserocr/config.py b/ocrd_tesserocr/config.py
index a58c1d8..01e0b23 100644
--- a/ocrd_tesserocr/config.py
+++ b/ocrd_tesserocr/config.py
@@ -1,6 +1,4 @@
-import os
 import json
-from os.path import join
 from pkg_resources import resource_string
 
 OCRD_TOOL = json.loads(resource_string(__name__, 'ocrd-tool.json').decode('utf8'))
diff --git a/ocrd_tesserocr/crop.py b/ocrd_tesserocr/crop.py
index ed17316..ca66f8e 100644
--- a/ocrd_tesserocr/crop.py
+++ b/ocrd_tesserocr/crop.py
@@ -18,27 +18,23 @@
 )
 from ocrd_modelfactory import page_from_file
 from ocrd_models.ocrd_page import (
-    CoordsType, AlternativeImageType,
+    CoordsType,
+    AlternativeImageType,
+    BorderType,
     to_xml
 )
-from ocrd_models.ocrd_page_generateds import BorderType
-from ocrd import Processor
 
 from .config import OCRD_TOOL
-from .recognize import polygon_for_parent
+from .recognize import TesserocrRecognize, polygon_for_parent
 
 TOOL = 'ocrd-tesserocr-crop'
 
-class TesserocrCrop(Processor):
-
+class TesserocrCrop(TesserocrRecognize):
     def __init__(self, *args, **kwargs):
-        kwargs['ocrd_tool'] = OCRD_TOOL['tools'][TOOL]
-        kwargs['version'] = OCRD_TOOL['version']
-        super(TesserocrCrop, self).__init__(*args, **kwargs)
-
-    @property
-    def moduledir(self):
-        return tesserocr.get_languages()[0]
+        kwargs.setdefault('ocrd_tool', OCRD_TOOL['tools'][TOOL])
+        super().__init__(*args, **kwargs)
+        if hasattr(self, 'parameter'):
+            self.logger = getLogger('processor.TesserocrCrop')
 
     def process(self):
         """Performs page cropping with Tesseract on the workspace.
@@ -57,7 +53,6 @@ def process(self):
         
         Produce new output files by serialising the resulting hierarchy.
         """
-        LOG = getLogger('processor.TesserocrCrop')
         assert_file_grp_cardinality(self.input_file_grp, 1)
         assert_file_grp_cardinality(self.output_file_grp, 1)
 
@@ -70,7 +65,7 @@ def process(self):
             for (n, input_file) in enumerate(self.input_files):
                 file_id = make_file_id(input_file, self.output_file_grp)
                 page_id = input_file.pageId or input_file.ID
-                LOG.info("INPUT FILE %i / %s", n, page_id)
+                self.logger.info("INPUT FILE %i / %s", n, page_id)
                 pcgts = page_from_file(self.workspace.download_file(input_file))
                 self.add_metadata(pcgts)
                 page = pcgts.get_Page()
@@ -79,8 +74,8 @@ def process(self):
                 border = page.get_Border()
                 if border:
                     left, top, right, bottom = bbox_from_points(border.get_Coords().points)
-                    LOG.warning('Overwriting existing Border: %i:%i,%i:%i',
-                                left, top, right, bottom)
+                    self.logger.warning('Overwriting existing Border: %i:%i,%i:%i',
+                                        left, top, right, bottom)
                 
                 page_image, page_xywh, page_image_info = self.workspace.image_from_page(
                     page, page_id,
@@ -89,15 +84,15 @@ def process(self):
                     feature_filter='cropped')
                 if self.parameter['dpi'] > 0:
                     dpi = self.parameter['dpi']
-                    LOG.info("Page '%s' images will use %d DPI from parameter override", page_id, dpi)
+                    self.logger.info("Page '%s' images will use %d DPI from parameter override", page_id, dpi)
                 elif page_image_info.resolution != 1:
                     dpi = page_image_info.resolution
                     if page_image_info.resolutionUnit == 'cm':
                         dpi = round(dpi * 2.54)
-                    LOG.info("Page '%s' images will use %d DPI from image meta-data", page_id, dpi)
+                    self.logger.info("Page '%s' images will use %d DPI from image meta-data", page_id, dpi)
                 else:
                     dpi = 0
-                    LOG.info("Page '%s' images will use DPI estimated from segmentation", page_id)
+                    self.logger.info("Page '%s' images will use DPI estimated from segmentation", page_id)
                 tessapi.SetVariable('user_defined_dpi', str(dpi))
                 if dpi:
                     zoom = 300 / dpi
@@ -109,9 +104,9 @@ def process(self):
 
                 pcgts.set_pcGtsId(file_id)
                 self.workspace.add_file(
-                    ID=file_id,
+                    file_id=file_id,
                     file_grp=self.output_file_grp,
-                    pageId=input_file.pageId,
+                    page_id=input_file.pageId,
                     mimetype=MIMETYPE_PAGE,
                     local_filename=os.path.join(self.output_file_grp,
                                                 file_id + '.xml'),
@@ -119,12 +114,11 @@ def process(self):
         
     def estimate_bounds(self, page, page_image, tessapi, zoom=1.0):
         """Get outer bounds of all (existing or detected) regions."""
-        LOG = getLogger('processor.TesserocrCrop')
         all_left = page_image.width
         all_top = page_image.height
         all_right = 0
         all_bottom = 0
-        LOG.info("Cropping with Tesseract")
+        self.logger.info("Cropping with Tesseract")
         tessapi.SetImage(page_image)
         # PSM.SPARSE_TEXT: get as much text as possible in no particular order
         # PSM.AUTO (default): includes tables (dangerous)
@@ -140,23 +134,23 @@ def estimate_bounds(self, page, page_image, tessapi, zoom=1.0):
             #
             ID = "region%04d" % index
             left, top, right, bottom = bbox_from_xywh(xywh)
-            LOG.debug("Detected text region '%s': %i:%i,%i:%i",
-                      ID, left, right, top, bottom)
+            self.logger.debug("Detected text region '%s': %i:%i,%i:%i",
+                              ID, left, right, top, bottom)
             # filter region results:
             bin_bbox = image.getbbox()
             if not bin_bbox:
                 # this does happen!
-                LOG.info("Ignoring region '%s' because its binarization is empty", ID)
+                self.logger.info("Ignoring region '%s' because its binarization is empty", ID)
                 continue
             width = bin_bbox[2]-bin_bbox[0]
             if width < 25 / zoom:
                 # we must be conservative here: page numbers are tiny regions, too!
-                LOG.info("Ignoring region '%s' because its width is too small (%d)", ID, width)
+                self.logger.info("Ignoring region '%s' because its width is too small (%d)", ID, width)
                 continue
             height = bin_bbox[3]-bin_bbox[1]
             if height < 25 / zoom:
                 # we must be conservative here: page numbers are tiny regions, too!
-                LOG.debug("Ignoring region '%s' because its height is too small (%d)", ID, height)
+                self.logger.debug("Ignoring region '%s' because its height is too small (%d)", ID, height)
                 continue
             all_left = min(all_left, left)
             all_top = min(all_top, top)
@@ -166,22 +160,21 @@ def estimate_bounds(self, page, page_image, tessapi, zoom=1.0):
         regions = page.get_AllRegions(classes=['Text'])
         for region in regions:
             left, top, right, bottom = bbox_from_points(region.get_Coords().points)
-            LOG.debug("Found existing text region '%s': %i:%i,%i:%i",
-                      region.id, left, right, top, bottom)
+            self.logger.debug("Found existing text region '%s': %i:%i,%i:%i",
+                              region.id, left, right, top, bottom)
             all_left = min(all_left, left)
             all_top = min(all_top, top)
             all_right = max(all_right, right)
             all_bottom = max(all_bottom, bottom)
-        LOG.info("Combined page bounds from text regions: %i:%i,%i:%i",
-                 all_left, all_right, all_top, all_bottom)
+        self.logger.info("Combined page bounds from text regions: %i:%i,%i:%i",
+                         all_left, all_right, all_top, all_bottom)
         return all_left, all_top, all_right, all_bottom
 
     def process_page(self, page, page_image, page_xywh, bounds, file_id, page_id):
         """Set the identified page border, if valid."""
-        LOG = getLogger('processor.TesserocrCrop')
         left, top, right, bottom = bounds
         if left >= right or top >= bottom:
-            LOG.error("Cannot find valid extent for page '%s'", page_id)
+            self.logger.error("Cannot find valid extent for page '%s'", page_id)
             return
         padding = self.parameter['padding']
         # add padding:
@@ -189,12 +182,12 @@ def process_page(self, page, page_image, page_xywh, bounds, file_id, page_id):
         right = min(right + padding, page_image.width)
         top = max(top - padding, 0)
         bottom = min(bottom + padding, page_image.height)
-        LOG.info("Padded page border: %i:%i,%i:%i", left, right, top, bottom)
+        self.logger.info("Padded page border: %i:%i,%i:%i", left, right, top, bottom)
         polygon = polygon_from_bbox(left, top, right, bottom)
         polygon = coordinates_for_segment(polygon, page_image, page_xywh)
         polygon = polygon_for_parent(polygon, page)
         if polygon is None:
-            LOG.error("Ignoring extant border")
+            self.logger.error("Ignoring extant border")
             return
         border = BorderType(Coords=CoordsType(
             points_from_polygon(polygon)))
diff --git a/ocrd_tesserocr/deskew.py b/ocrd_tesserocr/deskew.py
index 64c0a2c..a76e203 100644
--- a/ocrd_tesserocr/deskew.py
+++ b/ocrd_tesserocr/deskew.py
@@ -2,7 +2,6 @@
 
 import os.path
 import math
-from PIL import Image
 from tesserocr import (
     PyTessBaseAPI,
     PSM, OEM,
@@ -15,7 +14,6 @@
     getLogger,
     make_file_id,
     assert_file_grp_cardinality,
-    rotate_image, transpose_image,
     membername,
     MIMETYPE_PAGE
 )
@@ -25,18 +23,18 @@
     TextLineType, TextRegionType, PageType,
     to_xml
 )
-from ocrd import Processor
 
 from .config import OCRD_TOOL
+from .recognize import TesserocrRecognize
 
 TOOL = 'ocrd-tesserocr-deskew'
 
-class TesserocrDeskew(Processor):
-
+class TesserocrDeskew(TesserocrRecognize):
     def __init__(self, *args, **kwargs):
-        kwargs['ocrd_tool'] = OCRD_TOOL['tools'][TOOL]
-        kwargs['version'] = OCRD_TOOL['version']
-        super(TesserocrDeskew, self).__init__(*args, **kwargs)
+        kwargs.setdefault('ocrd_tool', OCRD_TOOL['tools'][TOOL])
+        super().__init__(*args, **kwargs)
+        if hasattr(self, 'parameter'):
+            self.logger = getLogger('processor.TesserocrDeskew')
 
     def process(self):
         """Performs deskewing of the page / region with Tesseract on the workspace.
@@ -56,7 +54,6 @@ def process(self):
         
         Produce a new output file by serialising the resulting hierarchy.
         """
-        LOG = getLogger('processor.TesserocrDeskew')
         assert_file_grp_cardinality(self.input_file_grp, 1)
         assert_file_grp_cardinality(self.output_file_grp, 1)
         oplevel = self.parameter['operation_level']
@@ -71,7 +68,7 @@ def process(self):
             for n, input_file in enumerate(self.input_files):
                 file_id = make_file_id(input_file, self.output_file_grp)
                 page_id = input_file.pageId or input_file.ID
-                LOG.info("INPUT FILE %i / %s", n, page_id)
+                self.logger.info("INPUT FILE %i / %s", n, page_id)
                 pcgts = page_from_file(self.workspace.download_file(input_file))
                 pcgts.set_pcGtsId(file_id)
                 self.add_metadata(pcgts)
@@ -85,18 +82,18 @@ def process(self):
                     feature_filter='deskewed' if oplevel == 'page' else '')
                 if self.parameter['dpi'] > 0:
                     dpi = self.parameter['dpi']
-                    LOG.info("Page '%s' images will use %d DPI from parameter override", page_id, dpi)
+                    self.logger.info("Page '%s' images will use %d DPI from parameter override", page_id, dpi)
                 elif page_image_info.resolution != 1:
                     dpi = page_image_info.resolution
                     if page_image_info.resolutionUnit == 'cm':
                         dpi = round(dpi * 2.54)
-                    LOG.info("Page '%s' images will use %d DPI from image meta-data", page_id, dpi)
+                    self.logger.info("Page '%s' images will use %d DPI from image meta-data", page_id, dpi)
                 else:
                     dpi = 0
-                    LOG.info("Page '%s' images will use DPI estimated from segmentation", page_id)
+                    self.logger.info("Page '%s' images will use DPI estimated from segmentation", page_id)
                 tessapi.SetVariable('user_defined_dpi', str(dpi))
                 
-                LOG.info("Deskewing on '%s' level in page '%s'", oplevel, page_id)
+                self.logger.info("Deskewing on '%s' level in page '%s'", oplevel, page_id)
                 
                 if oplevel == 'page':
                     self._process_segment(tessapi, page, page_image, page_xywh,
@@ -105,7 +102,7 @@ def process(self):
                 else:
                     regions = page.get_AllRegions(classes=['Text', 'Table'])
                     if not regions:
-                        LOG.warning("Page '%s' contains no text regions", page_id)
+                        self.logger.warning("Page '%s' contains no text regions", page_id)
                     for region in regions:
                         region_image, region_xywh = self.workspace.image_from_segment(
                             region, page_image, page_xywh,
@@ -120,7 +117,7 @@ def process(self):
                         elif isinstance(region, TextRegionType):
                             lines = region.get_TextLine()
                             if not lines:
-                                LOG.warning("Page '%s' region '%s' contains no lines", page_id, region.id)
+                                self.logger.warning("Page '%s' region '%s' contains no lines", page_id, region.id)
                             for line in lines:
                                 line_image, line_xywh = self.workspace.image_from_segment(
                                     line, region_image, region_xywh)
@@ -129,17 +126,16 @@ def process(self):
                                                       file_id + '_' + region.id + '_' + line.id)
                 
                 self.workspace.add_file(
-                    ID=file_id,
+                    file_id=file_id,
                     file_grp=self.output_file_grp,
-                    pageId=input_file.pageId,
+                    page_id=input_file.pageId,
                     mimetype=MIMETYPE_PAGE,
                     local_filename=os.path.join(self.output_file_grp, file_id + '.xml'),
                     content=to_xml(pcgts))
     
     def _process_segment(self, tessapi, segment, image, xywh, where, page_id, file_id):
-        LOG = getLogger('processor.TesserocrDeskew')
         if not image.width or not image.height:
-            LOG.warning("Skipping %s with zero size", where)
+            self.logger.warning("Skipping %s with zero size", where)
             return
         angle0 = xywh['angle'] # deskewing (w.r.t. top image) already applied to image
         angle = 0. # additional angle to be applied at current level
@@ -153,22 +149,22 @@ def _process_segment(self, tessapi, segment, image, xywh, where, page_id, file_i
             assert not math.isnan(osr['orient_conf']), \
                 "orientation detection failed (Tesseract probably compiled without legacy OEM, or osd model not installed)"
             if osr['orient_conf'] < self.parameter['min_orientation_confidence']:
-                LOG.info('ignoring OSD orientation result %d° clockwise due to low confidence %.0f in %s',
-                         osr['orient_deg'], osr['orient_conf'], where)
+                self.logger.info('ignoring OSD orientation result %d° clockwise due to low confidence %.0f in %s',
+                                 osr['orient_deg'], osr['orient_conf'], where)
             else:
-                LOG.info('applying OSD orientation result %d° clockwise with high confidence %.0f in %s',
-                         osr['orient_deg'], osr['orient_conf'], where)
+                self.logger.info('applying OSD orientation result %d° clockwise with high confidence %.0f in %s',
+                                 osr['orient_deg'], osr['orient_conf'], where)
                 # defined as 'the detected clockwise rotation of the input image'
                 # i.e. the same amount to be applied counter-clockwise for deskewing:
                 angle = osr['orient_deg']
             assert not math.isnan(osr['script_conf']), \
                 "script detection failed (Tesseract probably compiled without legacy OEM, or osd model not installed)"
             if osr['script_conf'] < 10:
-                LOG.info('ignoring OSD script result "%s" due to low confidence %.0f in %s',
-                         osr['script_name'], osr['script_conf'], where)
+                self.logger.info('ignoring OSD script result "%s" due to low confidence %.0f in %s',
+                                 osr['script_name'], osr['script_conf'], where)
             else:
-                LOG.info('applying OSD script result "%s" with high confidence %.0f in %s',
-                         osr['script_name'], osr['script_conf'], where)
+                self.logger.info('applying OSD script result "%s" with high confidence %.0f in %s',
+                                 osr['script_name'], osr['script_conf'], where)
                 if isinstance(segment, (TextLineType, TextRegionType, PageType)):
                     segment.set_primaryScript({
                         "Arabic": "Arab - Arabic",
@@ -214,7 +210,7 @@ def _process_segment(self, tessapi, segment, image, xywh, where, page_id, file_i
                         "Vietnamese": "Tavt - Tai Viet",
                     }.get(osr['script_name'], "Latn - Latin"))
         else:
-            LOG.warning('no OSD result in %s', where)
+            self.logger.warning('no OSD result in %s', where)
         if isinstance(segment, TextLineType):
             return
         #
@@ -222,7 +218,7 @@ def _process_segment(self, tessapi, segment, image, xywh, where, page_id, file_i
         #
         layout = tessapi.AnalyseLayout()
         if not layout:
-            LOG.warning('no result iterator in %s', where)
+            self.logger.warning('no result iterator in %s', where)
             return
         orientation, writing_direction, textline_order, deskew_angle = layout.Orientation()
         if isinstance(segment, (TextRegionType, PageType)):
@@ -243,11 +239,11 @@ def _process_segment(self, tessapi, segment, image, xywh, where, page_id, file_i
         # defined as 'how many radians does one have to rotate the block anti-clockwise'
         # i.e. positive amount to be applied counter-clockwise for deskewing:
         deskew_angle *= 180 / math.pi
-        LOG.info('orientation/deskewing for %s: %s / %s / %s / %.3f°', where,
-                  membername(Orientation, orientation),
-                  membername(WritingDirection, writing_direction),
-                  membername(TextlineOrder, textline_order),
-                  deskew_angle)
+        self.logger.info('orientation/deskewing for %s: %s / %s / %s / %.3f°', where,
+                         membername(Orientation, orientation),
+                         membername(WritingDirection, writing_direction),
+                         membername(TextlineOrder, textline_order),
+                         deskew_angle)
         # defined as 'the amount of clockwise rotation to be applied to the input image'
         # i.e. the negative amount to be applied counter-clockwise for deskewing:
         # (as defined in Tesseract OrientationIdToValue):
@@ -261,8 +257,8 @@ def _process_segment(self, tessapi, segment, image, xywh, where, page_id, file_i
             # because it is usually wrong when it deviates from OSD results.
             # (We do keep deskew_angle, though – see below.)
             # FIXME: revisit that decision after trying with api.set_min_orientation_margin
-            LOG.warning('inconsistent angles from layout analysis (%d) and orientation detection (%d) in %s',
-                        angle2, angle, where)
+            self.logger.warning('inconsistent angles from layout analysis (%d) and orientation detection (%d) in %s',
+                                angle2, angle, where)
         # annotate result:
         angle += deskew_angle
         # page angle: PAGE @orientation is defined clockwise,
diff --git a/ocrd_tesserocr/fontshape.py b/ocrd_tesserocr/fontshape.py
index 74763c7..bfac399 100644
--- a/ocrd_tesserocr/fontshape.py
+++ b/ocrd_tesserocr/fontshape.py
@@ -18,22 +18,18 @@
     TextStyleType,
     to_xml)
 from ocrd_modelfactory import page_from_file
-from ocrd import Processor
 
 from .config import OCRD_TOOL
+from .recognize import TesserocrRecognize
 
 TOOL = 'ocrd-tesserocr-fontshape'
 
-class TesserocrFontShape(Processor):
-
+class TesserocrFontShape(TesserocrRecognize):
     def __init__(self, *args, **kwargs):
-        kwargs['ocrd_tool'] = OCRD_TOOL['tools'][TOOL]
-        kwargs['version'] = OCRD_TOOL['version']
-        super(TesserocrFontShape, self).__init__(*args, **kwargs)
-
-    @property
-    def moduledir(self):
-        return get_languages()[0]
+        kwargs.setdefault('ocrd_tool', OCRD_TOOL['tools'][TOOL])
+        super().__init__(*args, **kwargs)
+        if hasattr(self, 'parameter'):
+            self.logger = getLogger('processor.TesserocrFontShape')
 
     def process(self):
         """Detect font shapes via rule-based OCR with Tesseract on the workspace.
@@ -50,8 +46,7 @@ def process(self):
         
         Produce new output files by serialising the resulting hierarchy.
         """
-        LOG = getLogger('processor.TesserocrFontShape')
-        LOG.debug("TESSDATA: %s, installed Tesseract models: %s", *get_languages())
+        self.logger.debug("TESSDATA: %s, installed Tesseract models: %s", *get_languages())
 
         assert_file_grp_cardinality(self.input_file_grp, 1)
         assert_file_grp_cardinality(self.output_file_grp, 1)
@@ -63,11 +58,11 @@ def process(self):
         with PyTessBaseAPI(#oem=OEM.TESSERACT_LSTM_COMBINED, # legacy required for OSD or WordFontAttributes!
                            oem=OEM.TESSERACT_ONLY, # legacy required for OSD or WordFontAttributes!
                            lang=model) as tessapi:
-            LOG.info("Using model '%s' in %s for recognition at the word level",
-                     model, get_languages()[0])
+            self.logger.info("Using model '%s' in %s for recognition at the word level",
+                             model, get_languages()[0])
             for (n, input_file) in enumerate(self.input_files):
                 page_id = input_file.pageId or input_file.ID
-                LOG.info("INPUT FILE %i / %s", n, page_id)
+                self.logger.info("INPUT FILE %i / %s", n, page_id)
                 pcgts = page_from_file(self.workspace.download_file(input_file))
                 self.add_metadata(pcgts)
                 page = pcgts.get_Page()
@@ -76,60 +71,57 @@ def process(self):
                     page, page_id)
                 if self.parameter['dpi'] > 0:
                     dpi = self.parameter['dpi']
-                    LOG.info("Page '%s' images will use %d DPI from parameter override", page_id, dpi)
+                    self.logger.info("Page '%s' images will use %d DPI from parameter override", page_id, dpi)
                 elif page_image_info.resolution != 1:
                     dpi = page_image_info.resolution
                     if page_image_info.resolutionUnit == 'cm':
                         dpi = round(dpi * 2.54)
-                    LOG.info("Page '%s' images will use %d DPI from image meta-data", page_id, dpi)
+                    self.logger.info("Page '%s' images will use %d DPI from image meta-data", page_id, dpi)
                 else:
                     dpi = 0
-                    LOG.info("Page '%s' images will use DPI estimated from segmentation", page_id)
+                    self.logger.info("Page '%s' images will use DPI estimated from segmentation", page_id)
                 tessapi.SetVariable('user_defined_dpi', str(dpi))
                 
-                LOG.info("Processing page '%s'", page_id)
+                self.logger.info("Processing page '%s'", page_id)
                 regions = page.get_AllRegions(classes=['Text'])
                 if not regions:
-                    LOG.warning("Page '%s' contains no text regions", page_id)
+                    self.logger.warning("Page '%s' contains no text regions", page_id)
                 else:
                     self._process_regions(tessapi, regions, page_image, page_coords)
                 
                 file_id = make_file_id(input_file, self.output_file_grp)
                 pcgts.set_pcGtsId(file_id)
                 self.workspace.add_file(
-                    ID=file_id,
+                    file_id=file_id,
                     file_grp=self.output_file_grp,
-                    pageId=input_file.pageId,
+                    page_id=input_file.pageId,
                     mimetype=MIMETYPE_PAGE,
                     local_filename=os.path.join(self.output_file_grp,
                                                 file_id + '.xml'),
                     content=to_xml(pcgts))
 
     def _process_regions(self, tessapi, regions, page_image, page_coords):
-        LOG = getLogger('processor.TesserocrFontShape')
         for region in regions:
             region_image, region_coords = self.workspace.image_from_segment(
                 region, page_image, page_coords)
             textlines = region.get_TextLine()
             if not textlines:
-                LOG.warning("Region '%s' contains no text lines", region.id)
+                self.logger.warning("Region '%s' contains no text lines", region.id)
             else:
                 self._process_lines(tessapi, textlines, region_image, region_coords)
 
     def _process_lines(self, tessapi, textlines, region_image, region_coords):
-        LOG = getLogger('processor.TesserocrFontShape')
         for line in textlines:
             line_image, line_coords = self.workspace.image_from_segment(
                 line, region_image, region_coords)
-            LOG.debug("Recognizing text in line '%s'", line.id)
+            self.logger.debug("Recognizing text in line '%s'", line.id)
             words = line.get_Word()
             if not words:
-                LOG.warning("Line '%s' contains no words", line.id)
+                self.logger.warning("Line '%s' contains no words", line.id)
             else:
                 self._process_words(tessapi, words, line_image, line_coords)
 
     def _process_words(self, tessapi, words, line_image, line_coords):
-        LOG = getLogger('processor.TesserocrFontShape')
         for word in words:
             word_image, word_coords = self.workspace.image_from_segment(
                 word, line_image, line_coords)
@@ -142,19 +134,19 @@ def _process_words(self, tessapi, words, line_image, line_coords):
             tessapi.Recognize()
             result_it = tessapi.GetIterator()
             if not result_it or result_it.Empty(RIL.WORD):
-                LOG.warning("No text in word '%s'", word.id)
+                self.logger.warning("No text in word '%s'", word.id)
                 continue
-            LOG.debug("Decoding text in word '%s'", word.id)
+            self.logger.debug("Decoding text in word '%s'", word.id)
             # trigger recognition
             word_text = result_it.GetUTF8Text(RIL.WORD)
-            LOG.debug('Word "%s" detected "%s"', word.id, word_text)
+            self.logger.debug('Word "%s" detected "%s"', word.id, word_text)
             textequiv = word.get_TextEquiv()
             if textequiv:
-                LOG.info('Word "%s" annotated "%s" / detected "%s"',
-                         word.id, textequiv[0].Unicode, word_text)
+                self.logger.info('Word "%s" annotated "%s" / detected "%s"',
+                                 word.id, textequiv[0].Unicode, word_text)
             word_attributes = result_it.WordFontAttributes()
             if word_attributes:
-                #LOG.debug("found font attributes: {}".format(word_attributes))
+                #self.logger.debug("found font attributes: {}".format(word_attributes))
                 word_style = TextStyleType(
                     fontSize=word_attributes['pointsize']
                     if 'pointsize' in word_attributes else None,
diff --git a/ocrd_tesserocr/recognize.py b/ocrd_tesserocr/recognize.py
index 23a03c7..f4148e4 100644
--- a/ocrd_tesserocr/recognize.py
+++ b/ocrd_tesserocr/recognize.py
@@ -1,7 +1,6 @@
 from __future__ import absolute_import
 from os.path import join
-from sys import exit, stdout
-from os import scandir
+from pathlib import Path
 import math
 import itertools
 from PIL import Image, ImageStat
@@ -9,8 +8,6 @@
 from scipy.sparse.csgraph import minimum_spanning_tree
 from shapely.geometry import Polygon, LineString
 from shapely.ops import unary_union, nearest_points
-from shutil import copyfileobj
-from pathlib import Path
 
 from tesserocr import (
     RIL, PSM, PT, OEM,
@@ -24,7 +21,6 @@
 from ocrd_utils import (
     getLogger,
     make_file_id,
-    initLogging,
     assert_file_grp_cardinality,
     shift_coordinates,
     coordinates_for_segment,
@@ -94,6 +90,7 @@ def InitFull(self, path=None, lang=None, oem=None, psm=None, variables=None):
         self.path = path or self.path
         self.lang = lang or self.lang
         self.oem = oem or self.oem
+        self.psm = psm or self.psm
         self.parameters = variables or self.parameters
         super().InitFull(path=self.path, lang=self.lang, oem=self.oem, variables=self.parameters)
 
@@ -127,14 +124,11 @@ def __exit__(self, exc_type, exc_val, exc_trace):
         return None
 
 class TesserocrRecognize(Processor):
-
     def __init__(self, *args, **kwargs):
-        kwargs['ocrd_tool'] = OCRD_TOOL['tools'][TOOL]
-        kwargs['version'] = OCRD_TOOL['version'] + ' (' + tesseract_version().split('\n')[0] + ')'
-
-        super(TesserocrRecognize, self).__init__(*args, **kwargs)
-        
-        if hasattr(self, 'workspace'):
+        kwargs.setdefault('ocrd_tool', OCRD_TOOL['tools'][TOOL])
+        kwargs.setdefault('version', OCRD_TOOL['version'] + ' (' + tesseract_version().split('\n')[0] + ')')
+        super().__init__(*args, **kwargs)
+        if hasattr(self, 'parameter'):
             self.logger = getLogger('processor.TesserocrRecognize')
 
     @property
@@ -277,7 +271,6 @@ def process(self):
         segment_only = outlevel == 'none' or not self.parameter.get('model', '')
         
         model = "eng"
-        self.languages = get_languages()[1]
         if 'model' in self.parameter:
             model = self.parameter['model']
             for sub_model in model.split('+'):
@@ -357,8 +350,8 @@ def process(self):
                 file_id = make_file_id(input_file, self.output_file_grp)
                 page_id = input_file.pageId or input_file.ID
                 self.logger.info("INPUT FILE %i / %s", n, page_id)
-                pcgts, pcgts_tree, pcgts_mapping, pcgts_invmap = page_from_file(self.workspace.download_file(input_file),
-                                                                                with_tree=True)
+                pcgts, _, pcgts_mapping, _ = page_from_file(self.workspace.download_file(input_file),
+                                                            with_tree=True)
                 pcgts.set_pcGtsId(file_id)
                 self.add_metadata(pcgts)
                 page = pcgts.get_Page()
@@ -423,6 +416,8 @@ def process(self):
                         # disable table detection here, so tables will be
                         # analysed as independent text/line blocks:
                         tessapi.SetVariable("textord_tabfind_find_tables", "0")
+                    if not segment_only:
+                        self._reinit(tessapi, page, pcgts_mapping)
                     tessapi.SetImage(page_image) # is already cropped to Border
                     tessapi.SetPageSegMode(PSM.SPARSE_TEXT
                                            if self.parameter['sparse_text']
@@ -431,7 +426,6 @@ def process(self):
                         self.logger.debug("Detecting regions in page '%s'", page_id)
                         tessapi.AnalyseLayout()
                     else:
-                        self._reinit(tessapi, page, pcgts_mapping)
                         self.logger.debug("Recognizing text in page '%s'", page_id)
                         tessapi.Recognize()
                     page_image_bin = tessapi.GetThresholdedImage()
@@ -465,8 +459,8 @@ def process(self):
                     self._process_existing_regions(tessapi, regions, page_image, page_coords, pcgts_mapping)
                 else:
                     self.logger.warning("Page '%s' contains no text regions (but segmentation is off)",
-                                            page_id)
-                
+                                        page_id)
+
                 # post-processing
                 # bottom-up text concatenation
                 if outlevel != 'none' and self.parameter.get('model', ''):
@@ -474,14 +468,14 @@ def process(self):
                 # bottom-up polygonal outline projection
                 # if inlevel != 'none' and self.parameter['shrink_polygons']:
                 #     page_shrink_higher_coordinate_levels(inlevel, outlevel, pcgts)
-                
+
                 self.workspace.add_file(
-                    ID=file_id,
+                    file_id=file_id,
                     file_grp=self.output_file_grp,
-                    pageId=input_file.pageId,
+                    page_id=input_file.pageId,
                     mimetype=MIMETYPE_PAGE,
                     local_filename=join(self.output_file_grp,
-                                                file_id + '.xml'),
+                                        file_id + '.xml'),
                     content=to_xml(pcgts))
 
     def _process_regions_in_page(self, result_it, page, page_coords, mapping, dpi):
@@ -881,6 +875,8 @@ def _process_existing_tables(self, tessapi, tables, page, page_image, page_coord
             if not table_image.width or not table_image.height:
                 self.logger.warning("Skipping table region '%s' with zero size", table.id)
                 continue
+            if not segment_only:
+                self._reinit(tessapi, table, mapping)
             if self.parameter['padding']:
                 tessapi.SetImage(pad_image(table_image, self.parameter['padding']))
                 table_coords['transform'] = shift_coordinates(
@@ -893,7 +889,6 @@ def _process_existing_tables(self, tessapi, tables, page, page_image, page_coord
                 self.logger.debug("Detecting cells in table '%s'", table.id)
                 tessapi.AnalyseLayout()
             else:
-                self._reinit(tessapi, table, mapping)
                 self.logger.debug("Recognizing text in table '%s'", table.id)
                 tessapi.Recognize()
             self._process_cells_in_table(tessapi.GetIterator(), table, roelem, table_coords, mapping)
@@ -908,6 +903,8 @@ def _process_existing_regions(self, tessapi, regions, page_image, page_coords, m
             if not region_image.width or not region_image.height:
                 self.logger.warning("Skipping text region '%s' with zero size", region.id)
                 continue
+            if not segment_only:
+                self._reinit(tessapi, region, mapping)
             if (region.get_TextEquiv() and not self.parameter['overwrite_text']
                 if self.parameter['textequiv_level'] in ['region', 'cell']
                 else self.parameter['segmentation_level'] != 'line'):
@@ -920,8 +917,6 @@ def _process_existing_regions(self, tessapi, regions, page_image, page_coords, m
             else:
                 tessapi.SetImage(region_image)
             tessapi.SetPageSegMode(PSM.SINGLE_BLOCK)
-            if not segment_only:
-                self._reinit(tessapi, region, mapping)
             # cell (region in table): we could enter from existing_tables or top-level existing regions
             if self.parameter['textequiv_level'] in ['region', 'cell']:
                 #if region.get_primaryScript() not in tessapi.GetLoadedLanguages()...
@@ -967,6 +962,8 @@ def _process_existing_lines(self, tessapi, textlines, region_image, region_coord
             if not line_image.width or not line_image.height:
                 self.logger.warning("Skipping text line '%s' with zero size", line.id)
                 continue
+            if not segment_only:
+                self._reinit(tessapi, line, mapping)
             if (line.get_TextEquiv() and not self.parameter['overwrite_text']
                 if self.parameter['textequiv_level'] == 'line'
                 else self.parameter['segmentation_level'] != 'word'):
@@ -982,8 +979,6 @@ def _process_existing_lines(self, tessapi, textlines, region_image, region_coord
                 tessapi.SetPageSegMode(PSM.RAW_LINE)
             else:
                 tessapi.SetPageSegMode(PSM.SINGLE_LINE)
-            if not segment_only:
-                self._reinit(tessapi, line, mapping)
             #if line.get_primaryScript() not in tessapi.GetLoadedLanguages()...
             if self.parameter['textequiv_level'] == 'line':
                 if line.get_TextEquiv():
@@ -1031,6 +1026,8 @@ def _process_existing_words(self, tessapi, words, line_image, line_coords, mappi
             if not word_image.width or not word_image.height:
                 self.logger.warning("Skipping word '%s' with zero size", word.id)
                 continue
+            if not segment_only:
+                self._reinit(tessapi, word, mapping)
             if (word.get_TextEquiv() and not self.parameter['overwrite_text']
                 if self.parameter['textequiv_level'] == 'word'
                 else self.parameter['segmentation_level'] != 'glyph'):
@@ -1043,8 +1040,6 @@ def _process_existing_words(self, tessapi, words, line_image, line_coords, mappi
             else:
                 tessapi.SetImage(word_image)
             tessapi.SetPageSegMode(PSM.SINGLE_WORD)
-            if not segment_only:
-                self._reinit(tessapi, word, mapping)
             if self.parameter['textequiv_level'] == 'word':
                 if word.get_TextEquiv():
                     if not self.parameter['overwrite_text']:
@@ -1089,6 +1084,7 @@ def _process_existing_glyphs(self, tessapi, glyphs, word_image, word_xywh, mappi
             if not glyph_image.width or not glyph_image.height:
                 self.logger.warning("Skipping glyph '%s' with zero size", glyph.id)
                 continue
+            self._reinit(tessapi, glyph, mapping)
             if glyph.get_TextEquiv() and not self.parameter['overwrite_text']:
                 pass # image not used here
             elif self.parameter['padding']:
@@ -1096,7 +1092,6 @@ def _process_existing_glyphs(self, tessapi, glyphs, word_image, word_xywh, mappi
             else:
                 tessapi.SetImage(glyph_image)
             tessapi.SetPageSegMode(PSM.SINGLE_CHAR)
-            self._reinit(tessapi, glyph, mapping)
             if glyph.get_TextEquiv():
                 if not self.parameter['overwrite_text']:
                     continue
@@ -1220,7 +1215,7 @@ def _reinit(self, tessapi, segment, mapping):
                             for name, val in params.items():
                                 tessapi.SetVariable(name, val)
                 else:
-                    self.logger.error("Cannot find segment '%s' in etree mapping, " \
+                    self.logger.error("Cannot find segment '%s' in etree mapping, "
                                       "ignoring xpath_parameters", ident)
             if self.parameter['xpath_model']:
                 if node is not None and node.attrib.get(at_ident, None) == ident:
@@ -1239,7 +1234,7 @@ def _reinit(self, tessapi, segment, mapping):
                         tessapi.Reset(lang=model)
                         return
                 else:
-                    self.logger.error("Cannot find segment '%s' in etree mapping, " \
+                    self.logger.error("Cannot find segment '%s' in etree mapping, "
                                       "ignoring xpath_model", ident)
             if self.parameter['auto_model']:
                 models = self.parameter['model'].split('+')
@@ -1397,7 +1392,7 @@ def page_update_higher_textequiv_levels(level, pcgts, overwrite=True):
                     for line, next_line in zip(lines, lines[1:]):
                         words = line.get_Word()
                         next_words = next_line.get_Word()
-                        if not(words and next_words and (words[-1].id, next_words[0].id) in joins):
+                        if not (words and next_words and (words[-1].id, next_words[0].id) in joins):
                             region_unicode += '\n'
                         region_unicode += page_element_unicode0(next_line)
                     region_conf = sum(page_element_conf0(line) for line in lines)
@@ -1448,10 +1443,14 @@ def join_segments(segments):
                           for segment in segments])
 
 def join_polygons(polygons, scale=20):
+    """construct concave hull (alpha shape) from input polygons by connecting their pairwise nearest points"""
+    return make_join([Polygon(poly) for poly in polygons], scale=scale).exterior.coords[:-1]
+
+def make_join(polygons, scale=20):
     """construct concave hull (alpha shape) from input polygons by connecting their pairwise nearest points"""
     # ensure input polygons are simply typed
     polygons = list(itertools.chain.from_iterable([
-        poly.geoms if poly.type in ['MultiPolygon', 'GeometryCollection']
+        poly.geoms if poly.geom_type in ['MultiPolygon', 'GeometryCollection']
         else [poly]
         for poly in polygons]))
     npoly = len(polygons)
@@ -1475,7 +1474,7 @@ def join_polygons(polygons, scale=20):
         bridgep = LineString(nearest).buffer(max(1, scale/5), resolution=1)
         polygons.append(bridgep)
     jointp = unary_union(polygons)
-    assert jointp.type == 'Polygon', jointp.wkt
+    assert jointp.geom_type == 'Polygon', jointp.wkt
     if jointp.minimum_clearance < 1.0:
         # follow-up calculations will necessarily be integer;
         # so anticipate rounding here and then ensure validity
@@ -1538,12 +1537,12 @@ def make_intersection(poly1, poly2):
         # outside of the valid Border of a deskewed/cropped page
         # (empty corners created by masking); will be ignored
         return None
-    if interp.type == 'GeometryCollection':
+    if interp.geom_type == 'GeometryCollection':
         # heterogeneous result: filter zero-area shapes (LineString, Point)
         interp = unary_union([geom for geom in interp.geoms if geom.area > 0])
-    if interp.type == 'MultiPolygon':
+    if interp.geom_type == 'MultiPolygon':
         # homogeneous result: construct convex hull to connect
-        interp = join_polygons(interp.geoms)
+        interp = make_join(interp.geoms)
     if interp.minimum_clearance < 1.0:
         # follow-up calculations will necessarily be integer;
         # so anticipate rounding here and then ensure validity
diff --git a/ocrd_tesserocr/segment.py b/ocrd_tesserocr/segment.py
index 6c2429b..a299089 100644
--- a/ocrd_tesserocr/segment.py
+++ b/ocrd_tesserocr/segment.py
@@ -1,35 +1,27 @@
 from __future__ import absolute_import
 
 from ocrd_utils import getLogger
-from ocrd import Processor
+from ocrd_validators import ParameterValidator
 
 from .config import OCRD_TOOL
 from .recognize import TesserocrRecognize
 
 TOOL = 'ocrd-tesserocr-segment'
+BASE_TOOL = 'ocrd-tesserocr-recognize'
 
-class TesserocrSegment(Processor):
-
+class TesserocrSegment(TesserocrRecognize):
     def __init__(self, *args, **kwargs):
-        kwargs['ocrd_tool'] = OCRD_TOOL['tools'][TOOL]
-        kwargs['version'] = OCRD_TOOL['version']
+        kwargs.setdefault('ocrd_tool', OCRD_TOOL['tools'][TOOL])
         super().__init__(*args, **kwargs)
+        if hasattr(self, 'parameter'):
+            self.parameter['overwrite_segments'] = True
+            self.parameter['segmentation_level'] = "region"
+            self.parameter['textequiv_level'] = "none"
+            # add default params
+            assert ParameterValidator(OCRD_TOOL['tools'][BASE_TOOL]).validate(self.parameter).is_valid
+            self.logger = getLogger('processor.TesserocrSegment')
 
-        if hasattr(self, 'workspace'):
-            recognize_kwargs = {**kwargs}
-            recognize_kwargs.pop('dump_json', None)
-            recognize_kwargs.pop('dump_module_dir', None)
-            recognize_kwargs.pop('show_help', None)
-            recognize_kwargs.pop('show_version', None)
-            recognize_kwargs['parameter'] = self.parameter
-            recognize_kwargs['parameter']['overwrite_segments'] = True
-            recognize_kwargs['parameter']['segmentation_level'] = "region"
-            recognize_kwargs['parameter']['textequiv_level'] = "none"
-            self.recognizer = TesserocrRecognize(self.workspace, **recognize_kwargs)
-            self.recognizer.logger = getLogger('processor.TesserocrSegment')
-
-    def process(self):
-        """Performs region and line segmentation with Tesseract on the workspace.
+TesserocrSegment.process.__doc__ = """Performs region and line segmentation with Tesseract on the workspace.
         
         Open and deserialize PAGE input files and their respective images,
         and remove any existing Region and ReadingOrder elements.
@@ -66,4 +58,3 @@ def process(self):
         
         Produce a new output file by serialising the resulting hierarchy.
         """
-        return self.recognizer.process()
diff --git a/ocrd_tesserocr/segment_line.py b/ocrd_tesserocr/segment_line.py
index a19e298..72137d8 100644
--- a/ocrd_tesserocr/segment_line.py
+++ b/ocrd_tesserocr/segment_line.py
@@ -1,36 +1,28 @@
 from __future__ import absolute_import
 
 from ocrd_utils import getLogger
-from ocrd import Processor
+from ocrd_validators import ParameterValidator
 
 from .config import OCRD_TOOL
 from .recognize import TesserocrRecognize
 
 TOOL = 'ocrd-tesserocr-segment-line'
+BASE_TOOL = 'ocrd-tesserocr-recognize'
 
-class TesserocrSegmentLine(Processor):
-
+class TesserocrSegmentLine(TesserocrRecognize):
     def __init__(self, *args, **kwargs):
-        kwargs['ocrd_tool'] = OCRD_TOOL['tools'][TOOL]
-        kwargs['version'] = OCRD_TOOL['version']
+        kwargs.setdefault('ocrd_tool', OCRD_TOOL['tools'][TOOL])
         super().__init__(*args, **kwargs)
+        if hasattr(self, 'parameter'):
+            self.parameter['overwrite_segments'] = self.parameter['overwrite_lines']
+            del self.parameter['overwrite_lines']
+            self.parameter['segmentation_level'] = "line"
+            self.parameter['textequiv_level'] = "line"
+            # add default params
+            assert ParameterValidator(OCRD_TOOL['tools'][BASE_TOOL]).validate(self.parameter).is_valid
+            self.logger = getLogger('processor.TesserocrSegmentLine')
 
-        if hasattr(self, 'workspace'):
-            recognize_kwargs = {**kwargs}
-            recognize_kwargs.pop('dump_json', None)
-            recognize_kwargs.pop('dump_module_dir', None)
-            recognize_kwargs.pop('show_help', None)
-            recognize_kwargs.pop('show_version', None)
-            recognize_kwargs['parameter'] = self.parameter
-            recognize_kwargs['parameter']['overwrite_segments'] = self.parameter['overwrite_lines']
-            del recognize_kwargs['parameter']['overwrite_lines']
-            recognize_kwargs['parameter']['segmentation_level'] = "line"
-            recognize_kwargs['parameter']['textequiv_level'] = "line"
-            self.recognizer = TesserocrRecognize(self.workspace, **recognize_kwargs)
-            self.recognizer.logger = getLogger('processor.TesserocrSegmentLine')
-
-    def process(self):
-        """Performs (text) line segmentation with Tesseract on the workspace.
+TesserocrSegmentLine.process.__doc__ = """Performs (text) line segmentation with Tesseract on the workspace.
         
         Open and deserialize PAGE input files and their respective images,
         then iterate over the element hierarchy down to the (text) region level,
@@ -48,4 +40,3 @@ def process(self):
         
         Produce a new output file by serialising the resulting hierarchy.
         """
-        return self.recognizer.process()
diff --git a/ocrd_tesserocr/segment_region.py b/ocrd_tesserocr/segment_region.py
index d3eed38..a223c16 100644
--- a/ocrd_tesserocr/segment_region.py
+++ b/ocrd_tesserocr/segment_region.py
@@ -1,38 +1,30 @@
 from __future__ import absolute_import
 
 from ocrd_utils import getLogger
-from ocrd import Processor
+from ocrd_validators import ParameterValidator
 
 from .config import OCRD_TOOL
 from .recognize import TesserocrRecognize
 
 TOOL = 'ocrd-tesserocr-segment-region'
+BASE_TOOL = 'ocrd-tesserocr-recognize'
 
-class TesserocrSegmentRegion(Processor):
-
+class TesserocrSegmentRegion(TesserocrRecognize):
     def __init__(self, *args, **kwargs):
-        kwargs['ocrd_tool'] = OCRD_TOOL['tools'][TOOL]
-        kwargs['version'] = OCRD_TOOL['version']
+        kwargs.setdefault('ocrd_tool', OCRD_TOOL['tools'][TOOL])
         super().__init__(*args, **kwargs)
-        
-        if hasattr(self, 'workspace'):
-            recognize_kwargs = {**kwargs}
-            recognize_kwargs.pop('dump_json', None)
-            recognize_kwargs.pop('dump_module_dir', None)
-            recognize_kwargs.pop('show_help', None)
-            recognize_kwargs.pop('show_version', None)
-            recognize_kwargs['parameter'] = self.parameter
-            recognize_kwargs['parameter']['overwrite_segments'] = self.parameter['overwrite_regions']
-            del recognize_kwargs['parameter']['overwrite_regions']
-            recognize_kwargs['parameter']['segmentation_level'] = "region"
-            recognize_kwargs['parameter']['textequiv_level'] = "region"
-            recognize_kwargs['parameter']['block_polygons'] = self.parameter['crop_polygons']
-            del recognize_kwargs['parameter']['crop_polygons']
-            self.recognizer = TesserocrRecognize(self.workspace, **recognize_kwargs)
-            self.recognizer.logger = getLogger('processor.TesserocrSegmentRegion')
+        if hasattr(self, 'parameter'):
+            self.parameter['overwrite_segments'] = self.parameter['overwrite_regions']
+            del self.parameter['overwrite_regions']
+            self.parameter['segmentation_level'] = "region"
+            self.parameter['textequiv_level'] = "region"
+            self.parameter['block_polygons'] = self.parameter['crop_polygons']
+            del self.parameter['crop_polygons']
+            # add default params
+            assert ParameterValidator(OCRD_TOOL['tools'][BASE_TOOL]).validate(self.parameter).is_valid
+            self.logger = getLogger('processor.TesserocrSegmentRegion')
 
-    def process(self):
-        """Performs region segmentation with Tesseract on the workspace.
+TesserocrSegmentRegion.process.__doc__ = """Performs region segmentation with Tesseract on the workspace.
         
         Open and deserialize PAGE input files and their respective images,
         and remove any existing Region and ReadingOrder elements
@@ -56,4 +48,3 @@ def process(self):
         
         Produce a new output file by serialising the resulting hierarchy.
         """
-        return self.recognizer.process()
diff --git a/ocrd_tesserocr/segment_table.py b/ocrd_tesserocr/segment_table.py
index f0e9308..edc85e5 100644
--- a/ocrd_tesserocr/segment_table.py
+++ b/ocrd_tesserocr/segment_table.py
@@ -1,36 +1,28 @@
 from __future__ import absolute_import
 
 from ocrd_utils import getLogger
-from ocrd import Processor
+from ocrd_validators import ParameterValidator
 
 from .config import OCRD_TOOL
 from .recognize import TesserocrRecognize
 
 TOOL = 'ocrd-tesserocr-segment-table'
+BASE_TOOL = 'ocrd-tesserocr-recognize'
 
-class TesserocrSegmentTable(Processor):
-
+class TesserocrSegmentTable(TesserocrRecognize):
     def __init__(self, *args, **kwargs):
-        kwargs['ocrd_tool'] = OCRD_TOOL['tools'][TOOL]
-        kwargs['version'] = OCRD_TOOL['version']
+        kwargs.setdefault('ocrd_tool', OCRD_TOOL['tools'][TOOL])
         super().__init__(*args, **kwargs)
-        
-        if hasattr(self, 'workspace'):
-            recognize_kwargs = {**kwargs}
-            recognize_kwargs.pop('dump_json', None)
-            recognize_kwargs.pop('dump_module_dir', None)
-            recognize_kwargs.pop('show_help', None)
-            recognize_kwargs.pop('show_version', None)
-            recognize_kwargs['parameter'] = self.parameter
-            recognize_kwargs['parameter']['overwrite_segments'] = self.parameter['overwrite_cells']
-            del recognize_kwargs['parameter']['overwrite_regions']
-            recognize_kwargs['parameter']['segmentation_level'] = "cell"
-            recognize_kwargs['parameter']['textequiv_level'] = "cell"
-            self.recognizer = TesserocrRecognize(self.workspace, **recognize_kwargs)
-            self.recognizer.logger = getLogger('processor.TesserocrSegmentTable')
+        if hasattr(self, 'parameter'):
+            self.parameter['overwrite_segments'] = self.parameter['overwrite_cells']
+            del self.parameter['overwrite_cells']
+            self.parameter['segmentation_level'] = "cell"
+            self.parameter['textequiv_level'] = "cell"
+            # add default params
+            assert ParameterValidator(OCRD_TOOL['tools'][BASE_TOOL]).validate(self.parameter).is_valid
+            self.logger = getLogger('processor.TesserocrSegmentTable')
 
-    def process(self):
-        """Performs table cell segmentation with Tesseract on the workspace.
+TesserocrSegmentTable.process.__doc__ = """Performs table cell segmentation with Tesseract on the workspace.
         
         Open and deserialize PAGE input files and their respective images,
         then iterate over the element hierarchy down to the region level
@@ -44,4 +36,3 @@ def process(self):
         
         Produce a new output file by serialising the resulting hierarchy.
         """
-        return self.recognizer.process()
diff --git a/ocrd_tesserocr/segment_word.py b/ocrd_tesserocr/segment_word.py
index 7627028..19134fe 100644
--- a/ocrd_tesserocr/segment_word.py
+++ b/ocrd_tesserocr/segment_word.py
@@ -1,36 +1,28 @@
 from __future__ import absolute_import
 
 from ocrd_utils import getLogger
-from ocrd import Processor
+from ocrd_validators import ParameterValidator
 
 from .config import OCRD_TOOL
 from .recognize import TesserocrRecognize
 
 TOOL = 'ocrd-tesserocr-segment-word'
+BASE_TOOL = 'ocrd-tesserocr-recognize'
 
-class TesserocrSegmentWord(Processor):
-
+class TesserocrSegmentWord(TesserocrRecognize):
     def __init__(self, *args, **kwargs):
-        kwargs['ocrd_tool'] = OCRD_TOOL['tools'][TOOL]
-        kwargs['version'] = OCRD_TOOL['version']
+        kwargs.setdefault('ocrd_tool', OCRD_TOOL['tools'][TOOL])
         super().__init__(*args, **kwargs)
-        
-        if hasattr(self, 'workspace'):
-            recognize_kwargs = {**kwargs}
-            recognize_kwargs.pop('dump_json', None)
-            recognize_kwargs.pop('dump_module_dir', None)
-            recognize_kwargs.pop('show_help', None)
-            recognize_kwargs.pop('show_version', None)
-            recognize_kwargs['parameter'] = self.parameter
-            recognize_kwargs['parameter']['overwrite_segments'] = self.parameter['overwrite_words']
-            del recognize_kwargs['parameter']['overwrite_words']
-            recognize_kwargs['parameter']['segmentation_level'] = "word"
-            recognize_kwargs['parameter']['textequiv_level'] = "word"
-            self.recognizer = TesserocrRecognize(self.workspace, **recognize_kwargs)
-            self.recognizer.logger = getLogger('processor.TesserocrSegmentWord')
+        if hasattr(self, 'parameter'):
+            self.parameter['overwrite_segments'] = self.parameter['overwrite_words']
+            del self.parameter['overwrite_words']
+            self.parameter['segmentation_level'] = "word"
+            self.parameter['textequiv_level'] = "word"
+            # add default params
+            assert ParameterValidator(OCRD_TOOL['tools'][BASE_TOOL]).validate(self.parameter).is_valid
+            self.logger = getLogger('processor.TesserocrSegmentWord')
 
-    def process(self):
-        """Performs word segmentation with Tesseract on the workspace.
+TesserocrSegmentWord.process.__doc__ = """Performs word segmentation with Tesseract on the workspace.
         
         Open and deserialize PAGE input files and their respective images,
         then iterate over the element hierarchy down to the textline level,
@@ -47,4 +39,3 @@ def process(self):
         
         Produce a new output file by serialising the resulting hierarchy.
         """
-        return self.recognizer.process()
diff --git a/requirements.txt b/requirements.txt
index 10032fe..3790fbe 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,4 +1,4 @@
-ocrd >= 2.40
+ocrd >= 2.48
 click
 tesserocr >= 2.5.2
 shapely >= 1.7.1


Test patch:
diff --git a/requirements_test.txt b/requirements_test.txt
index 580b799..b00e1ce 100644
--- a/requirements_test.txt
+++ b/requirements_test.txt
@@ -1,2 +1,3 @@
 pytest >= 4.4.0
+pytest-xdist
 coverage >= 4.5.2
diff --git a/test/base.py b/test/base.py
index f1407af..6969611 100644
--- a/test/base.py
+++ b/test/base.py
@@ -2,10 +2,14 @@
 
 import os
 import sys
-from unittest import TestCase, skip, main as unittests_main # pylint: disable=unused-import
+import shutil
+from tempfile import mkdtemp
+from unittest import TestCase as VanillaTestCase, main as unittests_main, skip # pylint: disable=unused-import
 import pytest
 
 from test.assets import assets
+from ocrd.resolver import Resolver
+from ocrd_utils import initLogging
 
 PWD = os.path.dirname(os.path.realpath(__file__))
 sys.path.append(PWD + '/../ocrd')
@@ -16,7 +20,33 @@ def main(fn=None):
     else:
         unittests_main()
 
-class CapturingTestCase(TestCase):
+class TestCase(VanillaTestCase):
+    METS_HEROLD_SMALL = None
+
+    @classmethod
+    def setUpClass(cls):
+        initLogging()
+
+    # we must intercept and reinstate CWD, because Processor.__init__ does chdir,
+    # but setUp needs to rm that copy afterwards; so the next test would end up
+    # in a non-existing CWD, causing os.getcwd() to fail
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+        self.old_cwd = os.getcwd()
+        self.resolver = Resolver()
+        self.workspace = None
+    def tearDown(self):
+        os.chdir(self.old_cwd)
+        if os.path.exists(self.workspace.directory):
+            shutil.rmtree(self.workspace.directory)
+        self.workspace = None
+    # isolate test methods from each other by cloning the asset workspace afresh
+    def setUp(self):
+        self.workspace = self.resolver.workspace_from_url(
+            self.METS_HEROLD_SMALL, download=True,
+            dst_dir=mkdtemp(prefix=self.__class__.__name__))
+
+class CapturingTestCase(VanillaTestCase):
     """
     A TestCase that needs to capture stderr/stdout and invoke click CLI.
     """
diff --git a/test/test_cli.py b/test/test_cli.py
index 61e04d3..7c60637 100644
--- a/test/test_cli.py
+++ b/test/test_cli.py
@@ -2,8 +2,6 @@
 
 from test.base import main
 from pathlib import Path
-from ocrd_utils import pushd_popd
-from ocrd_utils import disableLogging
 
 runner = CliRunner()
 
diff --git a/test/test_recognize.py b/test/test_recognize.py
index fef1d50..d67f65c 100644
--- a/test/test_recognize.py
+++ b/test/test_recognize.py
@@ -1,67 +1,156 @@
 import os
-import shutil
-
 from test.base import TestCase, main, assets, skip
 
-from ocrd.resolver import Resolver
+from ocrd_models.constants import NAMESPACES
+from ocrd_modelfactory import page_from_file
+from ocrd_utils import MIMETYPE_PAGE
+from ocrd_tesserocr import TesserocrDeskew
 from ocrd_tesserocr import TesserocrSegmentWord
 from ocrd_tesserocr import TesserocrSegmentLine
 from ocrd_tesserocr import TesserocrSegmentRegion
 from ocrd_tesserocr import TesserocrRecognize
+from ocrd_tesserocr import TesserocrFontShape
 
-#METS_HEROLD_SMALL = assets.url_of('SBB0000F29300010000/data/mets_one_file.xml')
-# as long as #96 remains, we cannot use workspaces which have local relative files:
-METS_HEROLD_SMALL = assets.url_of('kant_aufklaerung_1784-binarized/data/mets.xml')
-
-WORKSPACE_DIR = '/tmp/pyocrd-test-recognizer'
 
 class TestTesserocrRecognize(TestCase):
+    #METS_HEROLD_SMALL = assets.url_of('SBB0000F29300010000/data/mets_one_file.xml')
+    # as long as #96 remains, we cannot use workspaces which have local relative files:
+    METS_HEROLD_SMALL = assets.url_of('kant_aufklaerung_1784-binarized/data/mets.xml')
 
-    def setUp(self):
-        if os.path.exists(WORKSPACE_DIR):
-            shutil.rmtree(WORKSPACE_DIR)
-        os.makedirs(WORKSPACE_DIR)
-
-    #skip("Takes too long")
-    def runTest(self):
-        resolver = Resolver()
-        workspace = resolver.workspace_from_url(METS_HEROLD_SMALL, dst_dir=WORKSPACE_DIR)
+    def test_run_modular(self):
         TesserocrSegmentRegion(
-            workspace,
+            self.workspace,
             input_file_grp="OCR-D-IMG",
             output_file_grp="OCR-D-SEG-BLOCK"
         ).process()
-        workspace.save_mets()
-
         TesserocrSegmentLine(
-            workspace,
+            self.workspace,
             input_file_grp="OCR-D-SEG-BLOCK",
             output_file_grp="OCR-D-SEG-LINE"
         ).process()
-        workspace.save_mets()
-
         TesserocrRecognize(
-            workspace,
+            self.workspace,
             input_file_grp="OCR-D-SEG-LINE",
             output_file_grp="OCR-D-OCR-TESS",
-            parameter={'textequiv_level': 'line'} # add dep tesseract-ocr-script-frak: , 'model': 'Fraktur'
+            parameter={'textequiv_level': 'line', 'model': 'Fraktur'}
         ).process()
-        workspace.save_mets()
-
         TesserocrSegmentWord(
-            workspace,
+            self.workspace,
             input_file_grp="OCR-D-SEG-LINE",
             output_file_grp="OCR-D-SEG-WORD"
         ).process()
-        workspace.save_mets()
-
         TesserocrRecognize(
-            workspace,
+            self.workspace,
             input_file_grp="OCR-D-SEG-WORD",
             output_file_grp="OCR-D-OCR-TESS-W2C",
-            parameter={'textequiv_level': 'glyph'} # add dep tesseract-ocr-script-frak: , 'model': 'Fraktur'}
+            parameter={'segmentation_level': 'glyph', 'textequiv_level': 'glyph', 'model': 'Fraktur'}
+        ).process()
+        self.workspace.save_mets()
+        assert os.path.isdir(os.path.join(self.workspace.directory, 'OCR-D-OCR-TESS-W2C'))
+        results = self.workspace.find_files(file_grp='OCR-D-OCR-TESS-W2C', mimetype=MIMETYPE_PAGE)
+        result0 = next(results, False)
+        assert result0
+        _, result0, _, _ = page_from_file(result0, with_tree=True)
+        text0 = result0.xpath('//page:Glyph/page:TextEquiv/page:Unicode', namespaces=NAMESPACES)
+        assert len(text0) > 0
+
+    def test_run_modular_full(self):
+        TesserocrDeskew(
+            self.workspace,
+            input_file_grp="OCR-D-IMG",
+            output_file_grp="OCR-D-DESK",
+            parameter={"operation_level": "page"}
+        ).process()
+        TesserocrSegmentRegion(
+            self.workspace,
+            input_file_grp="OCR-D-DESK",
+            output_file_grp="OCR-D-SEG-BLOCK"
+        ).process()
+        TesserocrDeskew(
+            self.workspace,
+            input_file_grp="OCR-D-SEG-BLOCK",
+            output_file_grp="OCR-D-DESK-BLOCK",
+            parameter={"operation_level": "region"}
+        ).process()
+        TesserocrSegmentLine(
+            self.workspace,
+            input_file_grp="OCR-D-DESK-BLOCK",
+            output_file_grp="OCR-D-SEG-LINE"
+        ).process()
+        TesserocrRecognize(
+            self.workspace,
+            input_file_grp="OCR-D-SEG-LINE",
+            output_file_grp="OCR-D-OCR-TESS",
+            parameter={'textequiv_level': 'word', 'raw_lines': True, 'xpath_model': {'starts-with(@script,"Latn")': 'deu+eng', 'starts-with(@script,"Latf")': 'Fraktur'}, 'model': 'Fraktur+deu+eng'}
+        ).process()
+        TesserocrFontShape(
+            self.workspace,
+            input_file_grp="OCR-D-OCR-TESS",
+            output_file_grp="OCR-D-OCR-STYLE"
+        ).process()
+        self.workspace.save_mets()
+        assert os.path.isdir(os.path.join(self.workspace.directory, 'OCR-D-OCR-STYLE'))
+        results = self.workspace.find_files(file_grp='OCR-D-OCR-STYLE', mimetype=MIMETYPE_PAGE)
+        result0 = next(results, False)
+        assert result0
+        _, result0, _, _ = page_from_file(result0, with_tree=True)
+        text0 = result0.xpath('//page:Word/page:TextEquiv/page:Unicode', namespaces=NAMESPACES)
+        assert len(text0) > 0
+        style0 = result0.xpath('//page:Word/page:TextStyle', namespaces=NAMESPACES)
+        assert len(style0) > 0
+
+    def test_run_allinone(self):
+        TesserocrRecognize(
+            self.workspace,
+            input_file_grp="OCR-D-IMG",
+            output_file_grp="OCR-D-OCR-TESS-W2C",
+            parameter={'segmentation_level': 'region', 'textequiv_level': 'glyph', 'model': 'Fraktur'}
+        ).process()
+        self.workspace.save_mets()
+        assert os.path.isdir(os.path.join(self.workspace.directory, 'OCR-D-OCR-TESS-W2C'))
+        results = self.workspace.find_files(file_grp='OCR-D-OCR-TESS-W2C', mimetype=MIMETYPE_PAGE)
+        result0 = next(results, False)
+        assert result0
+        _, result0, _, _ = page_from_file(result0, with_tree=True)
+        text0 = result0.xpath('//page:Glyph/page:TextEquiv/page:Unicode', namespaces=NAMESPACES)
+        assert len(text0) > 0
+
+    def test_run_allinone_shrink(self):
+        TesserocrRecognize(
+            self.workspace,
+            input_file_grp="OCR-D-IMG",
+            output_file_grp="OCR-D-OCR-TESS-W2C",
+            parameter={'segmentation_level': 'region', 'textequiv_level': 'glyph', 'shrink_polygons': True, 'model': 'Fraktur'}
+        ).process()
+        self.workspace.save_mets()
+
+    def test_run_allinone_sparse(self):
+        TesserocrRecognize(
+            self.workspace,
+            input_file_grp="OCR-D-IMG",
+            output_file_grp="OCR-D-OCR-TESS-W2C",
+            parameter={'segmentation_level': 'region', 'textequiv_level': 'glyph', 'sparse_text': True, 'model': 'Fraktur'}
+        ).process()
+        self.workspace.save_mets()
+
+    def test_run_allineone_multimodel(self):
+        TesserocrRecognize(
+            self.workspace,
+            input_file_grp="OCR-D-IMG",
+            output_file_grp="OCR-D-OCR-TESS-W2C",
+            parameter={'segmentation_level': 'region', 'textequiv_level': 'glyph', 'model': 'Fraktur+eng+deu'}
+        ).process()
+        self.workspace.save_mets()
+
+    # @skip
+    def test_run_allinone_automodel(self):
+        TesserocrRecognize(
+            self.workspace,
+            input_file_grp="OCR-D-IMG",
+            output_file_grp="OCR-D-OCR-TESS-W2C",
+            parameter={'segmentation_level': 'region', 'textequiv_level': 'glyph', 'auto_model': True, 'model': 'Fraktur+eng+deu'}
         ).process()
-        workspace.save_mets()
+        self.workspace.save_mets()
 
 if __name__ == '__main__':
     main(__file__)
diff --git a/test/test_segment_line.py b/test/test_segment_line.py
index fa6901b..e73dea2 100644
--- a/test/test_segment_line.py
+++ b/test/test_segment_line.py
@@ -1,38 +1,34 @@
 import os
-import shutil
-
 from test.base import TestCase, main, assets
 
-from ocrd.resolver import Resolver
 from ocrd_tesserocr import TesserocrSegmentRegion
 from ocrd_tesserocr import TesserocrSegmentLine
-
-METS_HEROLD_SMALL = assets.url_of('SBB0000F29300010000/data/mets_one_file.xml')
-
-WORKSPACE_DIR = '/tmp/pyocrd-test-segment-line-tesserocr'
+from ocrd_tesserocr import TesserocrSegment
 
 class TestProcessorSegmentLineTesseract(TestCase):
+    METS_HEROLD_SMALL = assets.url_of('SBB0000F29300010000/data/mets_one_file.xml')
 
-    def setUp(self):
-        if os.path.exists(WORKSPACE_DIR):
-            shutil.rmtree(WORKSPACE_DIR)
-        os.makedirs(WORKSPACE_DIR)
-
-    def runTest(self):
-        resolver = Resolver()
-        workspace = resolver.workspace_from_url(METS_HEROLD_SMALL, dst_dir=WORKSPACE_DIR)
+    def test_run_modular(self):
         TesserocrSegmentRegion(
-            workspace,
+            self.workspace,
             input_file_grp="OCR-D-IMG",
             output_file_grp="OCR-D-SEG-BLOCK"
         ).process()
-        #  workspace.save_mets()
+        #  self.workspace.save_mets()
         TesserocrSegmentLine(
-            workspace,
+            self.workspace,
             input_file_grp="OCR-D-SEG-BLOCK",
             output_file_grp="OCR-D-SEG-LINE"
         ).process()
-        workspace.save_mets()
+        self.workspace.save_mets()
+
+    def test_run_allinone(self):
+        TesserocrSegment(
+            self.workspace,
+            input_file_grp="OCR-D-IMG",
+            output_file_grp="OCR-D-SEG"
+        ).process()
+        self.workspace.save_mets()
 
 if __name__ == '__main__':
-    main()
+    main(__file__)
diff --git a/test/test_segment_region.py b/test/test_segment_region.py
index 93b5e85..1580749 100644
--- a/test/test_segment_region.py
+++ b/test/test_segment_region.py
@@ -1,31 +1,45 @@
 import os
-import shutil
+from test.base import TestCase, main, assets, skip
 
-from test.base import TestCase, main, assets
-
-from ocrd.resolver import Resolver
 from ocrd_tesserocr import TesserocrSegmentRegion
 
-METS_HEROLD_SMALL = assets.url_of('SBB0000F29300010000/data/mets_one_file.xml')
+class TestTesserocrSegmentRegionTesseract(TestCase):
+    METS_HEROLD_SMALL = assets.url_of('SBB0000F29300010000/data/mets_one_file.xml')
 
-WORKSPACE_DIR = '/tmp/pyocrd-test-segment-region-tesserocr'
+    def test_run(self):
+        TesserocrSegmentRegion(
+            self.workspace,
+            input_file_grp="OCR-D-IMG",
+            output_file_grp="OCR-D-SEG-BLOCK"
+        ).process()
+        self.workspace.save_mets()
 
-class TestTesserocrSegmentRegionTesseract(TestCase):
+    def test_run_shrink(self):
+        TesserocrSegmentRegion(
+            self.workspace,
+            input_file_grp="OCR-D-IMG",
+            output_file_grp="OCR-D-SEG-BLOCK",
+            parameter={'shrink_polygons': True}
+        ).process()
+        self.workspace.save_mets()
 
-    def setUp(self):
-        if os.path.exists(WORKSPACE_DIR):
-            shutil.rmtree(WORKSPACE_DIR)
-        os.makedirs(WORKSPACE_DIR)
+    def test_run_sparse(self):
+        TesserocrSegmentRegion(
+            self.workspace,
+            input_file_grp="OCR-D-IMG",
+            output_file_grp="OCR-D-SEG-BLOCK",
+            parameter={'sparse_text': True}
+        ).process()
+        self.workspace.save_mets()
 
-    def runTest(self):
-        resolver = Resolver()
-        workspace = resolver.workspace_from_url(METS_HEROLD_SMALL, dst_dir=WORKSPACE_DIR)
+    def test_run_staves(self):
         TesserocrSegmentRegion(
-            workspace,
+            self.workspace,
             input_file_grp="OCR-D-IMG",
-            output_file_grp="OCR-D-SEG-BLOCK"
+            output_file_grp="OCR-D-SEG-BLOCK",
+            parameter={'find_staves': True, 'find_tables': False}
         ).process()
-        workspace.save_mets()
+        self.workspace.save_mets()
 
 if __name__ == '__main__':
-    main()
+    main(__file__)
diff --git a/test/test_segment_word.py b/test/test_segment_word.py
index 1c3b88c..ac2e9a9 100644
--- a/test/test_segment_word.py
+++ b/test/test_segment_word.py
@@ -1,44 +1,31 @@
 import os
-import shutil
-
 from test.base import TestCase, main, assets
 
-from ocrd import Resolver
 from ocrd_tesserocr import TesserocrSegmentRegion
 from ocrd_tesserocr import TesserocrSegmentLine
 from ocrd_tesserocr import TesserocrSegmentWord
 
-#METS_HEROLD_SMALL = assets.url_of('SBB0000F29300010000/mets_one_file.xml')
-METS_HEROLD_SMALL = assets.url_of('kant_aufklaerung_1784-binarized/data/mets.xml')
-
-WORKSPACE_DIR = '/tmp/pyocrd-test-segment-word-tesserocr'
-
 class TestProcessorSegmentWordTesseract(TestCase):
+    #METS_HEROLD_SMALL = assets.url_of('SBB0000F29300010000/mets_one_file.xml')
+    METS_HEROLD_SMALL = assets.url_of('kant_aufklaerung_1784-binarized/data/mets.xml')
 
-    def setUp(self):
-        if os.path.exists(WORKSPACE_DIR):
-            shutil.rmtree(WORKSPACE_DIR)
-        os.makedirs(WORKSPACE_DIR)
-
-    def runTest(self):
-        resolver = Resolver()
-        workspace = resolver.workspace_from_url(METS_HEROLD_SMALL, dst_dir=WORKSPACE_DIR)
+    def test_run_modular(self):
         TesserocrSegmentRegion(
-            workspace,
+            self.workspace,
             input_file_grp="OCR-D-IMG",
             output_file_grp="OCR-D-SEG-BLOCK"
         ).process()
         TesserocrSegmentLine(
-            workspace,
+            self.workspace,
             input_file_grp="OCR-D-SEG-BLOCK",
             output_file_grp="OCR-D-SEG-LINE"
         ).process()
         TesserocrSegmentWord(
-            workspace,
+            self.workspace,
             input_file_grp="OCR-D-SEG-LINE",
             output_file_grp="OCR-D-SEG-WORD"
         ).process()
-        workspace.save_mets()
+        self.workspace.save_mets()
 
 if __name__ == '__main__':
     main()