feat: anti-alias Ghostscript rasterization to improve OCR quality
Ghostscript 10.x renders aliased glyphs that OCR frequently misreads as extra word breaks or substituted characters. Enable text and graphics anti-aliasing (-dTextAlphaBits=4 -dGraphicsAlphaBits=4) for the contone raster devices, which empirically improves OCR accuracy on the Ghostscript path, especially for small fonts at moderate DPI. The 1-bit mono devices are excluded, since older Ghostscript rejects alpha bits on them and pngmonod performs its own anti-aliased downscaling. Also log which rasterizer rendered each page at debug verbosity and clarify the --rasterizer help text, so quality reports are easier to diagnose. The default rasterizer (auto) already prefers pypdfium2, which anti-aliases; this primarily benefits --rasterizer ghostscript and installs without pypdfium2. Closes #1439.
This commit is contained in:
@@ -62,6 +62,20 @@
|
||||
better input for OCR on faint or anti-aliased scans at negligible cost and
|
||||
no change to output file size, since the rasterized image is an
|
||||
intermediate that is discarded after OCR.
|
||||
- When rasterizing pages with Ghostscript, OCRmyPDF now enables text and
|
||||
graphics anti-aliasing (``-dTextAlphaBits=4 -dGraphicsAlphaBits=4``) for the
|
||||
grayscale and color raster devices. Ghostscript 10.x renders aliased glyphs
|
||||
that OCR frequently misreads as extra word breaks or substituted characters;
|
||||
anti-aliasing materially improves OCR accuracy on the Ghostscript
|
||||
rasterization path, especially for small fonts at moderate resolution. The
|
||||
1-bit monochrome devices are unaffected, since they perform their own
|
||||
anti-aliased downscaling and older Ghostscript versions reject alpha-bit
|
||||
options on them. Note that the default rasterizer (``--rasterizer auto``)
|
||||
prefers pypdfium2, which already anti-aliases; this change benefits users who
|
||||
select ``--rasterizer ghostscript`` or do not have pypdfium2 installed.
|
||||
OCRmyPDF now also logs which rasterizer rendered each page at debug verbosity
|
||||
(``-v 1``), and the ``--rasterizer`` help text explains the OCR-quality
|
||||
trade-off, to make such reports easier to diagnose. {issue}`1439`
|
||||
|
||||
## v17.5.0
|
||||
|
||||
|
||||
@@ -150,6 +150,19 @@ def rasterize_pdf(
|
||||
else:
|
||||
effective_dpi = raster_dpi
|
||||
|
||||
# Anti-alias text and vector graphics when rendering to a contone device.
|
||||
# Ghostscript 10.x renders aliased glyphs that OCR frequently misreads as
|
||||
# extra word breaks; anti-aliasing empirically improves OCR accuracy on the
|
||||
# Ghostscript path, especially for small fonts at moderate DPI (#1439).
|
||||
# The 1-bit mono devices do not accept alpha bits (older Ghostscript
|
||||
# rejects them) and pngmonod performs its own anti-aliased downscaling.
|
||||
mono_devices = (GhostscriptRasterDevice.PNGMONO, GhostscriptRasterDevice.PNGMONOD)
|
||||
antialias_args = (
|
||||
[]
|
||||
if raster_device in mono_devices
|
||||
else ['-dTextAlphaBits=4', '-dGraphicsAlphaBits=4']
|
||||
)
|
||||
|
||||
args_gs = (
|
||||
[
|
||||
GS,
|
||||
@@ -162,6 +175,7 @@ def rasterize_pdf(
|
||||
f'-dLastPage={pageno}',
|
||||
f'-r{effective_dpi.x:f}x{effective_dpi.y:f}',
|
||||
]
|
||||
+ antialias_args
|
||||
+ (['-dUseCropBox'] if use_cropbox else [])
|
||||
+ (['-dFILTERVECTOR'] if filter_vector else [])
|
||||
+ (['-dPDFSTOPONERROR'] if stop_on_error else [])
|
||||
|
||||
@@ -256,6 +256,8 @@ def rasterize_pdf_page(
|
||||
# Let pypdfium handle it (it will error in check_options if unavailable)
|
||||
return None
|
||||
|
||||
log.debug("Rasterizing page %d with the Ghostscript rasterizer", pageno)
|
||||
|
||||
ghostscript.rasterize_pdf(
|
||||
input_file,
|
||||
output_file,
|
||||
|
||||
@@ -255,6 +255,8 @@ def rasterize_pdf_page(
|
||||
if pdfium is None:
|
||||
return None # Fall back to Ghostscript
|
||||
|
||||
log.debug("Rasterizing page %d with the pypdfium2 rasterizer", pageno)
|
||||
|
||||
# Acquire lock to ensure thread-safe access to pypdfium2
|
||||
with (
|
||||
_pdfium_lock,
|
||||
|
||||
+7
-3
@@ -423,9 +423,13 @@ Online documentation is located at:
|
||||
'--rasterizer',
|
||||
choices=['auto', 'ghostscript', 'pypdfium'],
|
||||
default='auto',
|
||||
help="Choose PDF page rasterizer. 'auto' prefers pypdfium when available, "
|
||||
"falling back to Ghostscript. 'pypdfium' is faster but requires the "
|
||||
"pypdfium2 package. 'ghostscript' uses the traditional Ghostscript rasterizer.",
|
||||
help="Choose PDF page rasterizer. 'auto' (the default) prefers pypdfium2 "
|
||||
"when the pypdfium2 package is installed, falling back to Ghostscript "
|
||||
"otherwise. pypdfium2 anti-aliases page content and generally produces "
|
||||
"better input for OCR than Ghostscript 10.x, which can render aliased "
|
||||
"glyphs that OCR misreads as extra word breaks. 'pypdfium' forces the "
|
||||
"pypdfium2 rasterizer (requires the pypdfium2 package); 'ghostscript' "
|
||||
"forces the traditional Ghostscript rasterizer.",
|
||||
)
|
||||
advanced.add_argument(
|
||||
'--rotate-pages-threshold',
|
||||
|
||||
@@ -141,6 +141,62 @@ def test_rasterize_low_dpi_one_axis(francais, outdir):
|
||||
assert im.info['dpi'] == forced_dpi
|
||||
|
||||
|
||||
def _capture_rasterize_args(resources, outdir, raster_device):
|
||||
"""Run rasterize_pdf with the gs subprocess mocked; return the gs argv."""
|
||||
out = outdir / 'out.png'
|
||||
captured = {}
|
||||
|
||||
def fake_run(args, **kwargs):
|
||||
captured['args'] = list(args)
|
||||
# Produce a valid PNG so rasterize_pdf's post-processing succeeds.
|
||||
Image.new('RGB', (2, 2)).save(out)
|
||||
return subprocess.CompletedProcess(args, returncode=0, stdout=b'', stderr=b'')
|
||||
|
||||
with patch('ocrmypdf._exec.ghostscript.run', side_effect=fake_run):
|
||||
rasterize_pdf(
|
||||
resources / 'francais.pdf',
|
||||
out,
|
||||
raster_device=raster_device,
|
||||
raster_dpi=Resolution(150.0, 150.0),
|
||||
)
|
||||
return captured['args']
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
'raster_device',
|
||||
[
|
||||
GhostscriptRasterDevice.PNGGRAY,
|
||||
GhostscriptRasterDevice.PNG256,
|
||||
GhostscriptRasterDevice.PNG16M,
|
||||
],
|
||||
)
|
||||
def test_rasterize_antialiases_contone_devices(resources, outdir, raster_device):
|
||||
"""Contone raster devices receive anti-aliasing flags to aid OCR.
|
||||
|
||||
Ghostscript 10.x renders aliased glyphs that OCR misreads as extra word
|
||||
breaks; -dTextAlphaBits/-dGraphicsAlphaBits markedly improve accuracy,
|
||||
especially for small fonts at moderate DPI (see issue #1439).
|
||||
"""
|
||||
args = _capture_rasterize_args(resources, outdir, raster_device)
|
||||
assert '-dTextAlphaBits=4' in args
|
||||
assert '-dGraphicsAlphaBits=4' in args
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
'raster_device',
|
||||
[GhostscriptRasterDevice.PNGMONO, GhostscriptRasterDevice.PNGMONOD],
|
||||
)
|
||||
def test_rasterize_no_antialias_on_mono_devices(resources, outdir, raster_device):
|
||||
"""1-bit mono devices must not receive alpha-bit flags.
|
||||
|
||||
Older Ghostscript versions reject -dTextAlphaBits on 1-bit devices, and
|
||||
pngmonod performs its own anti-aliased downscaling.
|
||||
"""
|
||||
args = _capture_rasterize_args(resources, outdir, raster_device)
|
||||
assert not any(a.startswith('-dTextAlphaBits') for a in args)
|
||||
assert not any(a.startswith('-dGraphicsAlphaBits') for a in args)
|
||||
|
||||
|
||||
def test_generate_pdfa_default_jpeg_quality(outdir):
|
||||
"""When jpeg_quality is None, Ghostscript receives -dJPEGQ=95 (default)."""
|
||||
with (
|
||||
|
||||
Reference in New Issue
Block a user