2016-02-17 00:10:05 +00:00

332 lines
9.9 KiB
Python

import datetime
import tempfile
from multiprocessing.pool import Pool
import itertools
import langdetect
import os
import re
import subprocess
import pyocr
import shutil
from PIL import Image
from django.conf import settings
from django.utils import timezone
from django.template.defaultfilters import slugify
from logger.models import Log
from paperless.db import GnuPG
from .models import Sender, Tag, Document
from .languages import ISO639
def image_to_string(args):
self, png, lang = args
with Image.open(os.path.join(self.SCRATCH, png)) as f:
return self.OCR.image_to_string(f, lang=lang)
class OCRError(Exception):
pass
class ConsumerError(Exception):
pass
class Consumer(object):
"""
Loop over every file found in CONSUMPTION_DIR and:
1. Convert it to a greyscale png
2. Use tesseract on the png
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
CONSUME = settings.CONSUMPTION_DIR
THREADS = int(settings.OCR_THREADS) if settings.OCR_THREADS else None
OCR = pyocr.get_available_tools()[0]
DEFAULT_OCR_LANGUAGE = settings.OCR_LANGUAGE
REGEX_TITLE = re.compile(
r"^.*/(.*)\.(pdf|jpe?g|png|gif|tiff)$",
flags=re.IGNORECASE
)
REGEX_SENDER_TITLE = re.compile(
r"^.*/(.+) - (.*)\.(pdf|jpe?g|png|gif|tiff)$",
flags=re.IGNORECASE
)
REGEX_SENDER_TITLE_TAGS = re.compile(
r"^.*/(.*) - (.*) - ([a-z0-9\-,]*)\.(pdf|jpe?g|png|gif|tiff)$",
flags=re.IGNORECASE
)
def __init__(self, verbosity=1):
self.verbosity = verbosity
try:
os.makedirs(self.SCRATCH)
except FileExistsError:
pass
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))
def consume(self):
for doc in os.listdir(self.CONSUME):
doc = os.path.join(self.CONSUME, doc)
if not os.path.isfile(doc):
continue
if not re.match(self.REGEX_TITLE, doc):
continue
if doc in self._ignore:
continue
if self._is_ready(doc):
continue
Log.info("Consuming {}".format(doc), Log.COMPONENT_CONSUMER)
tempdir = tempfile.mkdtemp(prefix="paperless", dir=self.SCRATCH)
pngs = self._get_greyscale(tempdir, doc)
try:
text = self._get_ocr(pngs)
self._store(text, doc)
except OCRError:
self._ignore.append(doc)
Log.error("OCR FAILURE: {}".format(doc), Log.COMPONENT_CONSUMER)
continue
finally:
self._cleanup(tempdir, doc)
def _get_greyscale(self, tempdir, doc):
Log.debug(
"Generating greyscale image from {}".format(doc),
Log.COMPONENT_CONSUMER
)
png = os.path.join(tempdir, "convert-%04d.jpg")
subprocess.Popen((
self.CONVERT, "-density", "300", "-depth", "8",
"-type", "grayscale", doc, png
)).wait()
pngs = [os.path.join(tempdir, f) for f in os.listdir(tempdir) if f.startswith("convert")]
return sorted(filter(lambda f: os.path.isfile(f), pngs))
@staticmethod
def _guess_language(text):
try:
guess = langdetect.detect(text)
Log.debug(
"Language detected: {}".format(guess),
Log.COMPONENT_CONSUMER
)
return guess
except Exception as e:
Log.warning(
"Language detection error: {}".format(e), Log.COMPONENT_MAIL)
def _get_ocr(self, pngs):
"""
Attempts to do the best job possible OCR'ing the document based on
simple language detection trial & error.
"""
if not pngs:
raise OCRError
Log.debug("OCRing the document", Log.COMPONENT_CONSUMER)
# Since the division gets rounded down by int, this calculation works
# for every edge-case, i.e. 1
middle = int(len(pngs) / 2)
raw_text = self._ocr([pngs[middle]], self.DEFAULT_OCR_LANGUAGE)
guessed_language = self._guess_language(raw_text)
if not guessed_language or guessed_language not in ISO639:
Log.warning("Language detection failed!", Log.COMPONENT_CONSUMER)
if settings.FORGIVING_OCR:
Log.warning(
"As FORGIVING_OCR is enabled, we're going to make the best "
"with what we have.",
Log.COMPONENT_CONSUMER
)
raw_text = self._assemble_ocr_sections(pngs, middle, raw_text)
return raw_text
raise OCRError
if ISO639[guessed_language] == self.DEFAULT_OCR_LANGUAGE:
raw_text = self._assemble_ocr_sections(pngs, middle, raw_text)
return raw_text
try:
return self._ocr(pngs, ISO639[guessed_language])
except pyocr.pyocr.tesseract.TesseractError:
if settings.FORGIVING_OCR:
Log.warning(
"OCR for {} failed, but we're going to stick with what "
"we've got since FORGIVING_OCR is enabled.".format(
guessed_language
),
Log.COMPONENT_CONSUMER
)
raw_text = self._assemble_ocr_sections(pngs, middle, raw_text)
return raw_text
raise OCRError
def _assemble_ocr_sections(self, pngs, middle, text):
"""
Given a `middle` value and the text that middle page represents, we OCR
the remainder of the document and return the whole thing.
"""
text = self._ocr(pngs[:middle], self.DEFAULT_OCR_LANGUAGE) + text
text += self._ocr(pngs[middle+1:], self.DEFAULT_OCR_LANGUAGE)
return text
def _ocr(self, pngs, lang):
"""
Performs a single OCR attempt.
"""
if not pngs:
return ""
Log.debug("Parsing for {}".format(lang), Log.COMPONENT_CONSUMER)
with Pool(processes=self.THREADS) as pool:
r = pool.map(
image_to_string, itertools.product([self], pngs, [lang]))
r = " ".join(r)
# Strip out excess white space to allow matching to go smoother
return re.sub(r"\s+", " ", r)
def _guess_attributes_from_name(self, parseable):
"""
We use a crude naming convention to make handling the sender, title, and
tags easier:
"<sender> - <title> - <tags>.<suffix>"
"<sender> - <title>.<suffix>"
"<title>.<suffix>"
"""
def get_sender(sender_name):
return Sender.objects.get_or_create(
name=sender_name, defaults={"slug": slugify(sender_name)})[0]
def get_tags(tags):
r = []
for t in tags.split(","):
r.append(
Tag.objects.get_or_create(slug=t, defaults={"name": t})[0])
return tuple(r)
# First attempt: "<sender> - <title> - <tags>.<suffix>"
m = re.match(self.REGEX_SENDER_TITLE_TAGS, parseable)
if m:
return (
get_sender(m.group(1)),
m.group(2),
get_tags(m.group(3)),
m.group(4)
)
# Second attempt: "<sender> - <title>.<suffix>"
m = re.match(self.REGEX_SENDER_TITLE, parseable)
if m:
return get_sender(m.group(1)), m.group(2), (), m.group(3)
# That didn't work, so we assume sender and tags are None
m = re.match(self.REGEX_TITLE, parseable)
return None, m.group(1), (), m.group(2)
def _store(self, text, doc):
sender, title, tags, file_type = self._guess_attributes_from_name(doc)
tags = list(tags)
lower_text = text.lower()
relevant_tags = set(
[t for t in Tag.objects.all() if t.matches(lower_text)] + tags)
stats = os.stat(doc)
Log.debug("Saving record to database", Log.COMPONENT_CONSUMER)
document = Document.objects.create(
sender=sender,
title=title,
content=text,
file_type=file_type,
created=timezone.make_aware(
datetime.datetime.fromtimestamp(stats.st_mtime)),
modified=timezone.make_aware(
datetime.datetime.fromtimestamp(stats.st_mtime))
)
if relevant_tags:
tag_names = ", ".join([t.slug for t in relevant_tags])
Log.debug(
"Tagging with {}".format(tag_names), Log.COMPONENT_CONSUMER)
document.tags.add(*relevant_tags)
with open(doc, "rb") as unencrypted:
with open(document.source_path, "wb") as encrypted:
Log.debug("Encrypting", Log.COMPONENT_CONSUMER)
encrypted.write(GnuPG.encrypted(unencrypted))
def _cleanup(self, tempdir, doc):
# Remove temporary directory recursively
Log.debug("Deleting directory {}".format(tempdir), Log.COMPONENT_CONSUMER)
shutil.rmtree(tempdir)
# Remove doc
Log.debug("Deleting document {}".format(doc), Log.COMPONENT_CONSUMER)
os.unlink(doc)
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