Use Ghostscript for text region detection

Ghostscript txtwrite seems to be quite effective at the task.

Eliminates dependency on fitz
This commit is contained in:
James R. Barlow
2018-06-13 00:58:09 -07:00
parent 1dfbbdebf4
commit 8c84c515b6
5 changed files with 112 additions and 95 deletions
+1 -1
View File
@@ -257,7 +257,7 @@ setup(
'ruffus == 2.6.3', # pinned - ocrmypdf implements a 2.6.3 workaround
],
extras_require={
'fitz': ['PyMuPDF >= 1.12.5, != 1.13.3'],
'fitz': [], # Backward compatibility
},
tests_require=tests_require,
entry_points={
+52 -12
View File
@@ -48,24 +48,64 @@ def _gs_error_reported(stream):
return re.search(r'error', stream, flags=re.IGNORECASE)
def extract_text(input_file, pageno=1):
"""
Use the txtwrite device to get text layout information out
For details on options of -dTextFormat see
https://www.ghostscript.com/doc/current/VectorDevices.htm#TXT
Format is like
<page>
<line>
<span bbox="left top right bottom" font="..." size="...">
<char bbox="...." c="X"/>
:return: XML-ish text representation in bytes
"""
args_gs = [
'gs',
'-dQUIET',
'-dSAFER',
'-dBATCH',
'-dNOPAUSE',
'-sDEVICE=txtwrite',
'-dTextFormat=0',
'-dFirstPage=%i' % pageno,
'-dLastPage=%i' % pageno,
'-o', '-',
input_file
]
p = run(args_gs, stdout=PIPE, stderr=PIPE)
if p.returncode != 0:
raise SubprocessOutputError(
'Ghostscript text extraction failed\n%s\n%s\n%s',
input_file, p.stdout.decode(), p.stderr.decode())
return p.stdout
def rasterize_pdf(input_file, output_file, xres, yres, raster_device, log,
pageno=1, page_dpi=None, rotation=None):
"""
Rasterize one page of a PDF at resolution (xres, yres) in canvas units.
The image is sized to match the integer pixels dimensions implied by
The image is sized to match the integer pixels dimensions implied by
(xres, yres) even if those numbers are noninteger. The image's DPI will
be overridden with the values in page_dpi.
:param input_file: pathlike
:param output_file: pathlike
:param xres: resolution at which to rasterize page
:param yres:
:param raster_device:
:param log:
:param yres:
:param raster_device:
:param log:
:param pageno: page number to rasterize (beginning at page 1)
:param page_dpi: resolution tuple (x, y) overriding output image DPI
:return:
:param page_dpi: resolution tuple (x, y) overriding output image DPI
:return:
"""
res = xres, yres
int_res = round(xres), round(yres)
@@ -90,7 +130,7 @@ def rasterize_pdf(input_file, output_file, xres, yres, raster_device, log,
'-f',
fspath(input_file)
]
log.debug(args_gs)
p = run(args_gs, stdout=PIPE, stderr=STDOUT,
universal_newlines=True)
@@ -120,7 +160,7 @@ def rasterize_pdf(input_file, output_file, xres, yres, raster_device, log,
if rotation is not None:
log.debug("Rotating output by %i", rotation)
# rotation is a clockwise angle and Image.ROTATE_* is
# rotation is a clockwise angle and Image.ROTATE_* is
# counterclockwise so this cancels out the rotation
if rotation == 90:
im = im.transpose(Image.ROTATE_90)
@@ -156,8 +196,8 @@ def generate_pdfa(pdf_pages, output_file, compression, log,
"-dAutoFilterGrayImages=true",
]
# Older versions of Ghostscript expect a leading slash in
# sColorConversionStrategy, newer ones should not have it. See Ghostscript
# Older versions of Ghostscript expect a leading slash in
# sColorConversionStrategy, newer ones should not have it. See Ghostscript
# git commit fe1c025d.
strategy = 'RGB' if version() >= '9.19' else '/RGB'
-10
View File
@@ -16,13 +16,3 @@
# along with OCRmyPDF. If not, see <http://www.gnu.org/licenses/>.
"""Bindings to external libraries"""
import os as _os
try:
import fitz
except ImportError:
fitz = None
if _os.environ.get('_OCRMYPDF_NO_FITZ'):
fitz = None
+54 -67
View File
@@ -28,7 +28,7 @@ from pathlib import Path
from enum import Enum
from contextlib import contextmanager
from .lib import fitz
from .exec import ghostscript
from .helpers import universal_open, fspath
from pikepdf import PdfMatrix
@@ -503,70 +503,68 @@ def _find_images(*, pdf, container, shorthand=None):
yield from _find_form_xobject_images(pdf, container, contentsinfo)
def _naive_find_text(*, pdf, page):
if not(page.get('/Type') == '/Page' and '/Contents' in page):
# Not a page, or has no /Contents => no text
return False
# First we check the main content stream
contentsinfo = _interpret_contents(page, UNIT_SQUARE)
if contentsinfo.found_text:
return True
# Then see if there is a Form XObject with with a content stream
# that might have text. For full completeness we should recursively
# search nested Form XObjects, as we do with images. But that is
# rare.
if '/Resources' in page:
resources = page['/Resources']
if '/XObject' in resources:
xobjs = resources['/XObject'].as_dict()
for xobj in xobjs:
candidate = xobjs[xobj]
if candidate['/Subtype'] != '/Form':
continue
form_xobject = candidate
# Content stream is attached to Form XObject dictionary
sub_contentsinfo = _interpret_contents(
form_xobject, UNIT_SQUARE)
if sub_contentsinfo.found_text:
return True
return False
def _page_get_textblocks(infile, pageno):
"Smarter text detection"
"""Smarter text detection"""
import xml.etree.ElementTree as ET
doc = fitz.Document(infile)
if fitz.version[0] >= '1.13.0':
text = doc[pageno].getText('dict')
else:
import json
textjson = doc[pageno].getText('json')
text = json.loads(textjson)
if not text:
return
gstext = ghostscript.extract_text(infile, pageno+1)
root = ET.fromstring(gstext)
text['blocks'] = [blk for blk in text['blocks'] if blk['type'] == 0]
return text
def blocks():
for span in root.findall('.//span'):
bbox_str = span.attrib['bbox']
font_size = span.attrib['size']
pts = [int(pt) for pt in bbox_str.split()]
pts[1] = pts[1] - int(float(font_size) + 0.5)
bbox = tuple(pts)
yield bbox
def joined_blocks():
prev = None
for bbox in blocks():
if prev is None:
prev = bbox
if bbox[1] == prev[1] and bbox[3] == prev[3]:
gap = prev[2] - bbox[0]
height = bbox[3] - bbox[1]
if gap < height:
# Join boxes
prev = (prev[0], prev[1], bbox[2], bbox[3])
continue
# yield previously joined bboxes and start anew
yield prev
prev = bbox
if prev is not None:
yield prev
return [block for block in joined_blocks()]
def _page_has_text(text):
"Smarter text detection that ignores text in margins"
def _page_has_text(text_blocks, page_width, page_height):
"""Smarter text detection that ignores text in margins"""
pw, ph = text['width'], text['height']
pw, ph = float(page_width), float(page_height)
margin_ratio = 0.125
interior_bbox = fitz.Rect(
interior_bbox = (
margin_ratio * pw, margin_ratio * ph,
(1 - margin_ratio) * pw, (1 - margin_ratio) * ph
)
def rects_intersect(a, b):
"""
Where (a,b) are 4-tuple rects (left-0, top-1, right-2, bottom-3)
https://stackoverflow.com/questions/306316/determine-if-two-rectangles-overlap-each-other
Negative signs to account for our coordinates being in the fourth quadrant
and the formula assuming the first
"""
return a[0] < b[2] and a[2] > b[0] and -a[1] > -b[3] and -a[3] < -b[1]
has_text = False
for block in text['blocks']:
bbox = fitz.Rect(block['bbox'])
if bbox & interior_bbox:
for bbox in text_blocks:
if rects_intersect(bbox, interior_bbox):
has_text = True
break
return has_text
@@ -577,16 +575,15 @@ def _pdf_get_pageinfo(pdf, pageno: int, infile):
page = pdf.pages[pageno]
if fitz:
pageinfo['textinfo'] = _page_get_textblocks(str(infile), pageno)
pageinfo['has_text'] = _page_has_text(pageinfo['textinfo'])
else:
pageinfo['has_text'] = _naive_find_text(pdf=pdf, page=page)
pageinfo['textinfo'] = _page_get_textblocks(str(infile), pageno)
mediabox = [Decimal(d) for d in page.MediaBox.as_list()]
width_pt = mediabox[2] - mediabox[0]
height_pt = mediabox[3] - mediabox[1]
pageinfo['has_text'] = _page_has_text(
pageinfo['textinfo'], width_pt, height_pt)
userunit = page.get('/UserUnit', Decimal(1.0))
if not isinstance(userunit, Decimal):
userunit = Decimal(userunit)
@@ -666,9 +663,7 @@ class PageInfo:
return self._pageinfo['images']
def get_textareas(self):
if not fitz:
raise NotImplementedError("no impl without fitz")
yield from self._pageinfo['textinfo']['blocks']
yield from self._pageinfo['textinfo']
@property
def xres(self):
@@ -706,10 +701,6 @@ class PdfInfo:
def __init__(self, infile):
self._infile = infile
self._pages = _pdf_get_all_pageinfo(infile)
if fitz:
self._toc = fitz.Document(fspath(infile)).getToC()
else:
self._toc = []
@property
def pages(self):
@@ -730,10 +721,6 @@ class PdfInfo:
raise NotImplementedError("can't get filename from stream")
return self._infile
@property
def table_of_contents(self):
return self._toc
def __getitem__(self, item):
return self._pages[item]
+5 -5
View File
@@ -36,7 +36,6 @@ from .pdfinfo import PdfInfo, Encoding, Colorspace
from .pdfa import generate_pdfa_ps, encode_pdf_date
from .helpers import re_symlink, is_iterable_notstr, page_number, flatten_groups
from .exec import ghostscript, tesseract, qpdf
from .lib import fitz
from .exceptions import PdfMergeFailedError, UnsupportedImageFormatError, \
DpiError, PriorOcrFoundError, InputFileError
from . import leptonica
@@ -526,7 +525,7 @@ def select_ocr_image(
user."""
image = infiles[0]
if not fitz or context.get_options().force_ocr:
if context.get_options().force_ocr:
re_symlink(image, output_file, log)
return
@@ -537,16 +536,16 @@ def select_ocr_image(
from PIL import ImageDraw
from decimal import Decimal
white = ImageColor.getcolor('#ffffff', im.mode)
#pink = ImageColor.getcolor('#ff0080', im.mode)
draw = ImageDraw.ImageDraw(im)
xres, yres = im.info['dpi']
log.debug('resolution %r %r', xres, yres)
for textarea in pageinfo.get_textareas():
# Calculate resolution based on the image size and page dimensions
# without regard whatever resolution is in pageinfo (may differ or
# be None)
bbox = textarea['bbox']
log.debug('resolution %r %r', xres, yres)
bbox = textarea
pixcoords = [Decimal(bbox[0]) / Decimal(72) * xres,
Decimal(bbox[1]) / Decimal(72) * yres,
Decimal(bbox[2]) / Decimal(72) * xres,
@@ -554,6 +553,7 @@ def select_ocr_image(
pixcoords = [int(c) for c in pixcoords]
log.debug('blanking %r', pixcoords)
draw.rectangle(pixcoords, fill=white)
#draw.rectangle(pixcoords, outline=pink)
del draw