Files
paperless-ngx/src/documents/consumer.py
T

402 lines
12 KiB
Python
Raw Normal View History

2016-11-27 15:06:45 +00:00
import os
import re
import uuid
import shutil
2016-04-03 18:44:00 +01:00
import hashlib
2016-02-27 20:18:50 +00:00
import logging
2016-11-27 15:06:45 +00:00
import datetime
import tempfile
2016-02-14 15:57:42 +01:00
import itertools
import subprocess
2016-11-27 15:06:45 +00:00
from multiprocessing.pool import Pool
import pyocr
2016-11-27 15:06:45 +00:00
import langdetect
from PIL import Image
from django.conf import settings
from django.utils import timezone
from paperless.db import GnuPG
2016-11-27 15:06:45 +00:00
from pyocr.tesseract import TesseractError
from pyocr.libtesseract.tesseract_raw import \
TesseractError as OtherTesseractError
2016-03-28 14:16:26 +01:00
from .models import Tag, Document, FileInfo
from .signals import (
2016-11-27 15:06:45 +00:00
document_consumption_started,
document_consumption_finished
)
from .languages import ISO639
class OCRError(Exception):
pass
2016-02-06 17:05:36 +00:00
class ConsumerError(Exception):
pass
2016-02-14 16:09:52 +00:00
class Consumer(object):
2016-02-06 17:05:36 +00:00
"""
Loop over every file found in CONSUMPTION_DIR and:
2016-02-16 10:49:55 +01:00
1. Convert it to a greyscale pnm
2. Use tesseract on the pnm
2016-02-06 17:05:36 +00:00
3. Encrypt and store the document in the MEDIA_ROOT
4. Store the OCR'd text in the database
5. Delete the document and image(s)
"""
SCRATCH = settings.SCRATCH_DIR
CONVERT = settings.CONVERT_BINARY
2016-02-16 10:49:55 +01:00
UNPAPER = settings.UNPAPER_BINARY
CONSUME = settings.CONSUMPTION_DIR
THREADS = int(settings.OCR_THREADS) if settings.OCR_THREADS else None
2016-05-13 23:23:58 -04:00
DENSITY = settings.CONVERT_DENSITY if settings.CONVERT_DENSITY else 300
DEFAULT_OCR_LANGUAGE = settings.OCR_LANGUAGE
2016-02-28 00:39:40 +00:00
def __init__(self):
2016-02-27 20:18:50 +00:00
self.logger = logging.getLogger(__name__)
self.logging_group = None
try:
os.makedirs(self.SCRATCH)
except FileExistsError:
pass
2016-02-06 17:05:36 +00:00
self.stats = {}
self._ignore = []
if not self.CONSUME:
raise ConsumerError(
"The CONSUMPTION_DIR settings variable does not appear to be "
"set."
)
if not os.path.exists(self.CONSUME):
raise ConsumerError(
"Consumption directory {} does not exist".format(self.CONSUME))
2016-02-27 20:18:50 +00:00
def log(self, level, message):
getattr(self.logger, level)(message, extra={
"group": self.logging_group
2016-02-27 20:18:50 +00:00
})
2016-02-06 17:05:36 +00:00
def consume(self):
for doc in os.listdir(self.CONSUME):
doc = os.path.join(self.CONSUME, doc)
if not os.path.isfile(doc):
continue
2016-03-24 19:18:33 +00:00
if not re.match(FileInfo.REGEXES["title"], doc):
2016-02-06 17:05:36 +00:00
continue
if doc in self._ignore:
continue
2016-06-26 10:18:58 +02:00
if not self._is_ready(doc):
2016-02-06 17:05:36 +00:00
continue
2016-04-03 18:44:00 +01:00
if self._is_duplicate(doc):
self.log(
"info",
"Skipping {} as it appears to be a duplicate".format(doc)
)
self._ignore.append(doc)
continue
2016-02-27 20:18:50 +00:00
self.logging_group = uuid.uuid4()
self.log("info", "Consuming {}".format(doc))
2016-02-06 17:05:36 +00:00
document_consumption_started.send(
sender=self.__class__,
filename=doc,
logging_group=self.logging_group
)
tempdir = tempfile.mkdtemp(prefix="paperless", dir=self.SCRATCH)
2016-02-16 10:49:55 +01:00
imgs = self._get_greyscale(tempdir, doc)
2016-03-05 12:09:06 +00:00
thumbnail = self._get_thumbnail(tempdir, doc)
2016-02-06 17:05:36 +00:00
try:
document = self._store(self._get_ocr(imgs), doc, thumbnail)
2016-03-03 18:18:48 +00:00
except OCRError as e:
2016-02-06 17:05:36 +00:00
self._ignore.append(doc)
2016-03-03 18:18:48 +00:00
self.log("error", "OCR FAILURE for {}: {}".format(doc, e))
2016-02-20 22:30:01 +00:00
self._cleanup_tempdir(tempdir)
2016-02-06 17:05:36 +00:00
continue
2016-02-20 22:30:01 +00:00
else:
2016-02-20 22:30:01 +00:00
self._cleanup_tempdir(tempdir)
self._cleanup_doc(doc)
2016-02-06 17:05:36 +00:00
2016-10-26 09:52:09 +00:00
self.log(
"info",
"Document {} consumption finished".format(document)
)
document_consumption_finished.send(
sender=self.__class__,
document=document,
logging_group=self.logging_group
)
def _get_greyscale(self, tempdir, doc):
2016-03-05 12:09:06 +00:00
"""
Greyscale images are easier for Tesseract to OCR
"""
2016-02-27 20:18:50 +00:00
self.log("info", "Generating greyscale image from {}".format(doc))
2016-02-16 10:49:55 +01:00
# Convert PDF to multiple PNMs
pnm = os.path.join(tempdir, "convert-%04d.pnm")
run_convert(
self.CONVERT,
2016-05-13 23:23:58 -04:00
"-density", str(self.DENSITY),
"-depth", "8",
"-type", "grayscale",
doc, pnm,
)
2016-02-16 10:49:55 +01:00
# Get a list of converted images
pnms = []
for f in os.listdir(tempdir):
if f.endswith(".pnm"):
pnms.append(os.path.join(tempdir, f))
# Run unpaper in parallel on converted images
with Pool(processes=self.THREADS) as pool:
pool.map(run_unpaper, itertools.product([self.UNPAPER], pnms))
# Return list of converted images, processed with unpaper
pnms = []
2016-02-20 22:30:01 +00:00
for f in os.listdir(tempdir):
2016-02-16 10:49:55 +01:00
if f.endswith(".unpaper.pnm"):
pnms.append(os.path.join(tempdir, f))
2016-02-20 22:30:01 +00:00
2016-02-16 10:49:55 +01:00
return sorted(filter(lambda __: os.path.isfile(__), pnms))
2016-03-05 12:09:06 +00:00
def _get_thumbnail(self, tempdir, doc):
"""
The thumbnail of a PDF is just a 500px wide image of the first page.
"""
self.log("info", "Generating the thumbnail")
run_convert(
2016-03-05 12:09:06 +00:00
self.CONVERT,
"-scale", "500x5000",
"-alpha", "remove",
2016-03-25 20:33:00 +00:00
doc, os.path.join(tempdir, "convert-%04d.png")
)
2016-03-05 12:09:06 +00:00
return os.path.join(tempdir, "convert-0000.png")
2016-02-27 20:18:50 +00:00
def _guess_language(self, text):
2016-02-06 17:05:36 +00:00
try:
guess = langdetect.detect(text)
2016-02-27 20:18:50 +00:00
self.log("debug", "Language detected: {}".format(guess))
2016-02-06 17:05:36 +00:00
return guess
2016-02-14 17:13:48 +00:00
except Exception as e:
2016-02-27 20:18:50 +00:00
self.log("warning", "Language detection error: {}".format(e))
2016-02-06 17:05:36 +00:00
2016-02-16 10:49:55 +01:00
def _get_ocr(self, imgs):
2016-02-06 17:05:36 +00:00
"""
Attempts to do the best job possible OCR'ing the document based on
simple language detection trial & error.
"""
2016-02-16 10:49:55 +01:00
if not imgs:
2016-03-03 18:18:48 +00:00
raise OCRError("No images found")
2016-02-14 16:13:34 +01:00
2016-02-27 20:18:50 +00:00
self.log("info", "OCRing the document")
2016-02-14 17:13:48 +00:00
# Since the division gets rounded down by int, this calculation works
# for every edge-case, i.e. 1
2016-02-16 10:49:55 +01:00
middle = int(len(imgs) / 2)
raw_text = self._ocr([imgs[middle]], self.DEFAULT_OCR_LANGUAGE)
2016-02-06 17:05:36 +00:00
guessed_language = self._guess_language(raw_text)
2016-02-06 17:05:36 +00:00
if not guessed_language or guessed_language not in ISO639:
2016-02-27 20:18:50 +00:00
self.log("warning", "Language detection failed!")
if settings.FORGIVING_OCR:
2016-02-27 20:18:50 +00:00
self.log(
"warning",
2016-02-21 00:14:50 +00:00
"As FORGIVING_OCR is enabled, we're going to make the "
2016-02-27 20:18:50 +00:00
"best with what we have."
)
2016-02-16 10:49:55 +01:00
raw_text = self._assemble_ocr_sections(imgs, middle, raw_text)
return raw_text
2016-03-03 18:18:48 +00:00
raise OCRError("Language detection failed")
if ISO639[guessed_language] == self.DEFAULT_OCR_LANGUAGE:
2016-02-16 10:49:55 +01:00
raw_text = self._assemble_ocr_sections(imgs, middle, raw_text)
return raw_text
try:
2016-02-16 10:49:55 +01:00
return self._ocr(imgs, ISO639[guessed_language])
except pyocr.pyocr.tesseract.TesseractError:
if settings.FORGIVING_OCR:
2016-02-27 20:18:50 +00:00
self.log(
"warning",
"OCR for {} failed, but we're going to stick with what "
"we've got since FORGIVING_OCR is enabled.".format(
guessed_language
2016-02-27 20:18:50 +00:00
)
)
2016-02-16 10:49:55 +01:00
raw_text = self._assemble_ocr_sections(imgs, middle, raw_text)
return raw_text
2016-03-03 18:18:48 +00:00
raise OCRError(
"The guessed language is not available in this instance of "
"Tesseract."
)
2016-02-16 10:49:55 +01:00
def _assemble_ocr_sections(self, imgs, middle, text):
2016-02-14 17:13:48 +00:00
"""
Given a `middle` value and the text that middle page represents, we OCR
the remainder of the document and return the whole thing.
"""
2016-02-16 10:49:55 +01:00
text = self._ocr(imgs[:middle], self.DEFAULT_OCR_LANGUAGE) + text
text += self._ocr(imgs[middle + 1:], self.DEFAULT_OCR_LANGUAGE)
2016-02-14 17:13:48 +00:00
return text
2016-02-16 10:49:55 +01:00
def _ocr(self, imgs, lang):
2016-02-06 17:05:36 +00:00
"""
Performs a single OCR attempt.
"""
2016-02-16 10:49:55 +01:00
if not imgs:
2016-02-14 16:13:34 +01:00
return ""
2016-02-27 20:18:50 +00:00
self.log("info", "Parsing for {}".format(lang))
2016-02-14 15:57:42 +01:00
with Pool(processes=self.THREADS) as pool:
2016-02-16 10:49:55 +01:00
r = pool.map(image_to_string, itertools.product(imgs, [lang]))
2016-02-14 16:09:52 +00:00
r = " ".join(r)
# Strip out excess white space to allow matching to go smoother
2016-10-12 01:46:34 +02:00
return strip_excess_whitespace(r)
2016-03-05 12:09:06 +00:00
def _store(self, text, doc, thumbnail):
file_info = FileInfo.from_path(doc)
stats = os.stat(doc)
2016-02-27 20:18:50 +00:00
self.log("debug", "Saving record to database")
created = file_info.created or timezone.make_aware(
datetime.datetime.fromtimestamp(stats.st_mtime))
2016-04-11 23:28:12 +01:00
with open(doc, "rb") as f:
document = Document.objects.create(
correspondent=file_info.correspondent,
title=file_info.title,
content=text,
file_type=file_info.extension,
checksum=hashlib.md5(f.read()).hexdigest(),
created=created,
modified=created
2016-04-11 23:28:12 +01:00
)
relevant_tags = set(list(Tag.match_all(text)) + list(file_info.tags))
if relevant_tags:
tag_names = ", ".join([t.slug for t in relevant_tags])
2016-02-27 20:18:50 +00:00
self.log("debug", "Tagging with {}".format(tag_names))
document.tags.add(*relevant_tags)
2016-03-05 12:09:06 +00:00
# Encrypt and store the actual document
with open(doc, "rb") as unencrypted:
with open(document.source_path, "wb") as encrypted:
2016-03-05 12:09:06 +00:00
self.log("debug", "Encrypting the document")
encrypted.write(GnuPG.encrypted(unencrypted))
# Encrypt and store the thumbnail
with open(thumbnail, "rb") as unencrypted:
with open(document.thumbnail_path, "wb") as encrypted:
self.log("debug", "Encrypting the thumbnail")
encrypted.write(GnuPG.encrypted(unencrypted))
2016-02-27 20:18:50 +00:00
self.log("info", "Completed")
return document
2016-02-27 20:18:50 +00:00
def _cleanup_tempdir(self, d):
self.log("debug", "Deleting directory {}".format(d))
2016-02-20 22:30:01 +00:00
shutil.rmtree(d)
2016-02-27 20:18:50 +00:00
def _cleanup_doc(self, doc):
self.log("debug", "Deleting document {}".format(doc))
os.unlink(doc)
2016-02-06 17:05:36 +00:00
def _is_ready(self, doc):
"""
Detect whether `doc` is ready to consume or if it's still being written
to by the uploader.
"""
t = os.stat(doc).st_mtime
if self.stats.get(doc) == t:
del(self.stats[doc])
return True
self.stats[doc] = t
return False
2016-02-27 20:18:50 +00:00
2016-04-03 18:44:00 +01:00
@staticmethod
def _is_duplicate(doc):
with open(doc, "rb") as f:
checksum = hashlib.md5(f.read()).hexdigest()
return Document.objects.filter(checksum=checksum).exists()
2016-02-27 20:18:50 +00:00
2016-10-12 01:46:34 +02:00
def strip_excess_whitespace(text):
collapsed_spaces = re.sub(r"([^\S\r\n]+)", " ", text)
2016-10-26 09:32:59 +00:00
no_leading_whitespace = re.sub(
"([\n\r]+)([^\S\n\r]+)", '\\1', collapsed_spaces)
2016-10-12 01:46:34 +02:00
no_trailing_whitespace = re.sub("([^\S\n\r]+)$", '', no_leading_whitespace)
return no_trailing_whitespace
2016-02-27 20:18:50 +00:00
def image_to_string(args):
2016-02-16 10:49:55 +01:00
img, lang = args
2016-02-27 20:18:50 +00:00
ocr = pyocr.get_available_tools()[0]
2016-02-16 10:49:55 +01:00
with Image.open(os.path.join(Consumer.SCRATCH, img)) as f:
2016-02-27 20:18:50 +00:00
if ocr.can_detect_orientation():
try:
orientation = ocr.detect_orientation(f, lang=lang)
f = f.rotate(orientation["angle"], expand=1)
2016-11-27 15:06:45 +00:00
except (TesseractError, OtherTesseractError):
2016-02-27 20:18:50 +00:00
pass
return ocr.image_to_string(f, lang=lang)
2016-02-16 10:49:55 +01:00
def run_unpaper(args):
unpaper, pnm = args
subprocess.Popen(
(unpaper, pnm, pnm.replace(".pnm", ".unpaper.pnm"))).wait()
def run_convert(*args):
2016-04-10 17:39:49 +01:00
environment = os.environ.copy()
if settings.CONVERT_MEMORY_LIMIT:
environment["MAGICK_MEMORY_LIMIT"] = settings.CONVERT_MEMORY_LIMIT
if settings.CONVERT_TMPDIR:
environment["MAGICK_TMPDIR"] = settings.CONVERT_TMPDIR
subprocess.Popen(args, env=environment).wait()