aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--.gitignore1
-rw-r--r--bsie/reader/preview/__init__.py39
-rw-r--r--bsie/reader/preview/_pg.py86
-rw-r--r--bsie/reader/preview/_pillow.py44
-rw-r--r--bsie/reader/preview/_rawpy.py66
-rw-r--r--bsie/reader/preview/utils.py39
-rw-r--r--setup.py1
-rw-r--r--test/reader/preview/__init__.py0
-rw-r--r--test/reader/preview/invalid.foo0
-rw-r--r--test/reader/preview/invalid.jpg0
-rw-r--r--test/reader/preview/load_nef.py28
-rw-r--r--test/reader/preview/test_pg.py82
-rw-r--r--test/reader/preview/test_pillow.py50
-rw-r--r--test/reader/preview/test_preview.py77
-rw-r--r--test/reader/preview/test_rawpy.py59
-rw-r--r--test/reader/preview/test_utils.py44
-rw-r--r--test/reader/preview/testfile.pdfbin0 -> 7295 bytes
-rw-r--r--test/reader/preview/testimage.jpgbin0 -> 6476 bytes
18 files changed, 616 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
index 304ae08..c046d71 100644
--- a/.gitignore
+++ b/.gitignore
@@ -24,5 +24,6 @@ doc/build/
# testing data
test/reader/image/testimage.nef*
+test/reader/preview/testimage.nef*
## EOF ##
diff --git a/bsie/reader/preview/__init__.py b/bsie/reader/preview/__init__.py
new file mode 100644
index 0000000..3e69a4a
--- /dev/null
+++ b/bsie/reader/preview/__init__.py
@@ -0,0 +1,39 @@
+"""
+
+Part of the bsie module.
+A copy of the license is provided with the project.
+Author: Matthias Baumgartner, 2022
+"""
+# imports
+import typing
+
+# external imports
+import PIL.Image
+
+# inner-module imports
+from .. import chain
+
+# constants
+_FILE_FORMAT_READERS: typing.Sequence[str] = (
+ # native image formats
+ __package__ + '._pillow.PillowPreviewReader',
+ __package__ + '._rawpy.RawpyPreviewReader',
+ # multiformat readers
+ __package__ + '._pg.PreviewGeneratorReader',
+ )
+
+# exports
+__all__: typing.Sequence[str] = (
+ 'Preview',
+ )
+
+
+## code ##
+
+class Preview(chain.ReaderChain[typing.Callable[[int], PIL.Image.Image]]): # pylint: disable=too-few-public-methods
+ """Create a preview from a file."""
+
+ def __init__(self, cfg: typing.Optional[typing.Any] = None):
+ super().__init__(_FILE_FORMAT_READERS, cfg)
+
+## EOF ##
diff --git a/bsie/reader/preview/_pg.py b/bsie/reader/preview/_pg.py
new file mode 100644
index 0000000..097c513
--- /dev/null
+++ b/bsie/reader/preview/_pg.py
@@ -0,0 +1,86 @@
+"""
+
+Part of the bsie module.
+A copy of the license is provided with the project.
+Author: Matthias Baumgartner, 2022
+"""
+# standard imports
+from functools import partial
+import contextlib
+import io
+import os
+import shutil
+import tempfile
+import typing
+
+# external imports
+from preview_generator.manager import PreviewManager
+import PIL.Image
+
+# bsie imports
+from bsie.utils import errors
+
+# inner-module imports
+from .. import base
+
+# exports
+__all__: typing.Sequence[str] = (
+ 'PreviewGeneratorReader',
+ )
+
+
+## code ##
+
+class PreviewGeneratorReader(base.Reader):
+ """Uses preview_generator to create previews for various data formats.
+ See `https://github.com/algoo/preview-generator`_ for details.
+ """
+
+ # PreviewManager instance.
+ _mngr: PreviewManager
+
+ # Set of mime types supported by PreviewManager.
+ _supported_mimetypes: typing.Set[str]
+
+ # PreviewManager cache.
+ _cache: str
+
+ # Determines whether the cache directory should be deleted after use.
+ _cleanup: bool
+
+ def __init__(self, cache: typing.Optional[str] = None):
+ # initialize cache directory
+ # TODO: initialize in memory, e.g., via PyFilesystem
+ if cache is None:
+ self._cache = tempfile.mkdtemp(prefix='bsie-preview-cache-')
+ self._cleanup = True
+ else:
+ self._cache = cache
+ self._cleanup = False
+ # create preview generator
+ with contextlib.redirect_stderr(io.StringIO()):
+ self._mngr = PreviewManager(self._cache, create_folder=True)
+ self._supported_mimetypes = set(self._mngr.get_supported_mimetypes())
+
+ def __del__(self):
+ if self._cleanup:
+ shutil.rmtree(self._cache, ignore_errors=True)
+
+ def __call__(self, path: str) -> typing.Callable[[int], PIL.Image.Image]:
+ if not os.path.exists(path):
+ raise errors.ReaderError(path)
+ if self._mngr.get_mimetype(path) not in self._supported_mimetypes:
+ raise errors.UnsupportedFileFormatError(path)
+ return partial(self._preview_callback, path)
+
+ def _preview_callback(self, path: str, max_side: int) -> PIL.Image.Image:
+ """Produce a jpeg preview of *path* with at most *max_side* side length."""
+ try:
+ # generate the preview
+ preview_path = self._mngr.get_jpeg_preview(path, width=max_side, height=max_side)
+ # open the preview and return
+ return PIL.Image.open(preview_path)
+ except Exception as err: # FIXME: less generic exception!
+ raise errors.ReaderError(path) from err
+
+## EOF ##
diff --git a/bsie/reader/preview/_pillow.py b/bsie/reader/preview/_pillow.py
new file mode 100644
index 0000000..174d509
--- /dev/null
+++ b/bsie/reader/preview/_pillow.py
@@ -0,0 +1,44 @@
+"""
+
+Part of the bsie module.
+A copy of the license is provided with the project.
+Author: Matthias Baumgartner, 2022
+"""
+# standard imports
+from functools import partial
+import typing
+
+# external imports
+import PIL.Image
+
+# bsie imports
+from bsie.utils import errors
+
+# inner-module imports
+from . import utils
+from .. import base
+
+# exports
+__all__: typing.Sequence[str] = (
+ 'PillowPreviewReader',
+ )
+
+
+## code ##
+
+class PillowPreviewReader(base.Reader):
+ """Produce previews for image files using the Pillow library."""
+
+ def __call__(self, path: str) -> typing.Callable[[int], PIL.Image.Image]:
+ try:
+ # open file with PIL
+ img = PIL.Image.open(path)
+ # return callback
+ return partial(utils.resize, img)
+ except PIL.UnidentifiedImageError as err:
+ # failed to open, skip file
+ raise errors.UnsupportedFileFormatError(path) from err
+ except IOError as err:
+ raise errors.ReaderError(path) from err
+
+# EOF ##
diff --git a/bsie/reader/preview/_rawpy.py b/bsie/reader/preview/_rawpy.py
new file mode 100644
index 0000000..2c20a48
--- /dev/null
+++ b/bsie/reader/preview/_rawpy.py
@@ -0,0 +1,66 @@
+"""
+
+Part of the bsie module.
+A copy of the license is provided with the project.
+Author: Matthias Baumgartner, 2022
+"""
+# standard imports
+from functools import partial
+import typing
+
+# external imports
+import PIL.Image
+import rawpy
+
+# bsie imports
+from bsie.utils import errors, filematcher
+
+# inner-module imports
+from . import utils
+from .. import base
+
+# constants
+MATCH_RULE = 'mime={image/x-nikon-nef} | extension={nef}'
+
+# exports
+__all__: typing.Sequence[str] = (
+ 'RawpyPreviewReader',
+ )
+
+
+## code ##
+
+class RawpyPreviewReader(base.Reader):
+ """Produce previews for raw image files using the rawpy library."""
+
+ # file matcher
+ _match: filematcher.Matcher
+
+ # additional kwargs to rawpy's postprocess
+ _rawpy_kwargs: typing.Dict[str, typing.Any]
+
+ def __init__(self, **rawpy_kwargs):
+ match_rule = rawpy_kwargs.pop('file_match_rule', MATCH_RULE)
+ self._match = filematcher.parse(match_rule)
+ self._rawpy_kwargs = rawpy_kwargs
+
+ def __call__(self, path: str) -> typing.Callable[[int], PIL.Image.Image]:
+ # perform quick checks first
+ if not self._match(path):
+ raise errors.UnsupportedFileFormatError(path)
+
+ try:
+ # open file with rawpy
+ ary = rawpy.imread(path).postprocess(**self._rawpy_kwargs)
+ # convert to PIL.Image
+ img = PIL.Image.fromarray(ary)
+ # return callback
+ return partial(utils.resize, img)
+
+ except (rawpy.LibRawFatalError, # pylint: disable=no-member # pylint doesn't find the errors
+ rawpy.NotSupportedError, # pylint: disable=no-member
+ rawpy.LibRawNonFatalError, # pylint: disable=no-member
+ ) as err:
+ raise errors.ReaderError(path) from err
+
+## EOF ##
diff --git a/bsie/reader/preview/utils.py b/bsie/reader/preview/utils.py
new file mode 100644
index 0000000..2ef1562
--- /dev/null
+++ b/bsie/reader/preview/utils.py
@@ -0,0 +1,39 @@
+"""
+
+Part of the tagit module.
+A copy of the license is provided with the project.
+Author: Matthias Baumgartner, 2022
+"""
+# standard imports
+import typing
+
+# external imports
+import PIL.Image
+
+# exports
+__all__: typing.Sequence[str] = (
+ 'resize',
+ )
+
+
+## code ##
+
+def resize(
+ img: PIL.Image.Image,
+ max_size: int,
+ ) -> PIL.Image.Image:
+ """Resize an image to a given maximum side length."""
+ # determine target dimensions
+ ratio = img.width / img.height
+ if img.width > img.height:
+ width, height = max_size, round(max_size / ratio)
+ else:
+ width, height = round(ratio * max_size), max_size
+ # rescale and return
+ return img.resize(
+ (width, height),
+ resample=PIL.Image.Resampling.LANCZOS, # create high-quality image
+ reducing_gap=3.0, # optimize computation via fast size reduction
+ )
+
+## EOF ##
diff --git a/setup.py b/setup.py
index 6dad7ac..d45f178 100644
--- a/setup.py
+++ b/setup.py
@@ -20,6 +20,7 @@ setup(
'python-magic',
'rdflib', # only for tests
'requests', # only for tests
+ 'preview_generator', # also depends on some system packages
),
python_requires=">=3.7",
extra_require=(
diff --git a/test/reader/preview/__init__.py b/test/reader/preview/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/test/reader/preview/__init__.py
diff --git a/test/reader/preview/invalid.foo b/test/reader/preview/invalid.foo
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/test/reader/preview/invalid.foo
diff --git a/test/reader/preview/invalid.jpg b/test/reader/preview/invalid.jpg
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/test/reader/preview/invalid.jpg
diff --git a/test/reader/preview/load_nef.py b/test/reader/preview/load_nef.py
new file mode 100644
index 0000000..5ba0adc
--- /dev/null
+++ b/test/reader/preview/load_nef.py
@@ -0,0 +1,28 @@
+"""
+
+Part of the bsie test suite.
+A copy of the license is provided with the project.
+Author: Matthias Baumgartner, 2022
+"""
+# standard imports
+import os
+
+# external imports
+import requests
+
+# constants
+IMAGE_URL = 'http://igsor.net/eik7AhvohghaeN5.nef'
+
+## code ##
+
+def get():
+ """Download a raw test image."""
+ target = os.path.join(os.path.dirname(__file__), 'testimage.nef')
+ if not os.path.exists(target):
+ with open(target, 'wb') as ofile:
+ ans = requests.get(IMAGE_URL)
+ ofile.write(ans.content)
+
+
+
+## EOF ##
diff --git a/test/reader/preview/test_pg.py b/test/reader/preview/test_pg.py
new file mode 100644
index 0000000..e492cfa
--- /dev/null
+++ b/test/reader/preview/test_pg.py
@@ -0,0 +1,82 @@
+"""
+
+Part of the bsie test suite.
+A copy of the license is provided with the project.
+Author: Matthias Baumgartner, 2022
+"""
+# standard imports
+from functools import partial
+import os
+import shutil
+import tempfile
+import unittest
+
+# external imports
+import PIL.Image
+
+# bsie imports
+from bsie.utils import errors
+
+# objects to test
+from bsie.reader.preview._pg import PreviewGeneratorReader
+
+
+## code ##
+
+class TestPreviewGeneratorReader(unittest.TestCase):
+ def test_call(self):
+ rdr = PreviewGeneratorReader()
+ # inexistent file raises a ReaderError
+ self.assertRaises(errors.ReaderError, rdr,
+ os.path.join(os.path.dirname(__file__), 'missing.jpg'))
+ # unsupported file type raises an UnsupportedFileFormatError
+ self.assertRaises(errors.UnsupportedFileFormatError, rdr,
+ os.path.join(os.path.dirname(__file__), 'invalid.foo'))
+ # invalid file raises a ReaderError
+ self.assertRaises(errors.ReaderError,
+ rdr(os.path.join(os.path.dirname(__file__), 'invalid.jpg')), 100)
+
+ # proper file produces a generator
+ gen = rdr(os.path.join(os.path.dirname(__file__), 'testimage.jpg'))
+ self.assertIsInstance(gen, partial)
+ # generator produces an image
+ img = gen(10)
+ self.assertIsInstance(img, PIL.Image.Image)
+ self.assertEqual(img.size, (10, 10))
+ self.assertEqual(sum(img.getdata()), 0)
+ # cleanup
+ img.close()
+
+ # preview generator can also extract data from non-image files
+ gen = rdr(os.path.join(os.path.dirname(__file__), 'testfile.pdf'))
+ self.assertIsInstance(gen, partial)
+ # generator produces an image
+ img = gen(10)
+ self.assertIsInstance(img, PIL.Image.Image)
+ self.assertEqual(img.size, (8, 10))
+ self.assertEqual(sum(img.getdata()), 20258)
+ # cleanup
+ img.close()
+
+ # can define a cache dir
+ pg_dir = tempfile.mkdtemp(prefix='bsie-test')
+ self.assertTrue(os.path.exists(pg_dir))
+ rdr = PreviewGeneratorReader(cache=pg_dir)
+ gen = rdr(os.path.join(os.path.dirname(__file__), 'testimage.jpg'))
+ img = gen(10)
+ self.assertIsInstance(img, PIL.Image.Image)
+ self.assertEqual(img.size, (10, 10))
+ self.assertEqual(sum(img.getdata()), 0)
+ img.close()
+ del rdr
+ # cache dir still exists after instance deletion
+ self.assertTrue(os.path.exists(pg_dir))
+ shutil.rmtree(pg_dir, ignore_errors=True)
+
+
+## main ##
+
+if __name__ == '__main__':
+ unittest.main()
+
+## EOF ##
diff --git a/test/reader/preview/test_pillow.py b/test/reader/preview/test_pillow.py
new file mode 100644
index 0000000..ca38d89
--- /dev/null
+++ b/test/reader/preview/test_pillow.py
@@ -0,0 +1,50 @@
+"""
+
+Part of the bsie test suite.
+A copy of the license is provided with the project.
+Author: Matthias Baumgartner, 2022
+"""
+# standard imports
+from functools import partial
+import os
+import unittest
+
+# external imports
+import PIL.Image
+
+# bsie imports
+from bsie.utils import errors
+
+# objects to test
+from bsie.reader.preview._pillow import PillowPreviewReader
+
+
+## code ##
+
+class TestPillowPreviewReader(unittest.TestCase):
+ def test_call(self):
+ rdr = PillowPreviewReader()
+ # raises exception when image cannot be read
+ self.assertRaises(errors.ReaderError, rdr,
+ os.path.join(os.path.dirname(__file__), 'invalid.jpg'))
+ # raises exception when image has invalid type
+ self.assertRaises(errors.UnsupportedFileFormatError, rdr,
+ os.path.join(os.path.dirname(__file__), 'invalid.foo'))
+ # proper file produces a generator
+ gen = rdr(os.path.join(os.path.dirname(__file__), 'testimage.jpg'))
+ self.assertIsInstance(gen, partial)
+ # generator produces an image
+ img = gen(10)
+ self.assertIsInstance(img, PIL.Image.Image)
+ self.assertEqual(img.size, (10, 10))
+ self.assertEqual(sum(band for pix in img.getdata() for band in pix), 0)
+ # cleanup
+ img.close()
+
+
+## main ##
+
+if __name__ == '__main__':
+ unittest.main()
+
+## EOF ##
diff --git a/test/reader/preview/test_preview.py b/test/reader/preview/test_preview.py
new file mode 100644
index 0000000..fde610f
--- /dev/null
+++ b/test/reader/preview/test_preview.py
@@ -0,0 +1,77 @@
+"""
+
+Part of the bsie test suite.
+A copy of the license is provided with the project.
+Author: Matthias Baumgartner, 2022
+"""
+# standard imports
+from functools import partial
+import importlib
+import os
+import unittest
+
+# external imports
+import PIL.Image
+
+# bsie imports
+from bsie.utils import errors
+
+# objects to test
+from bsie.reader.preview import Preview
+
+
+## code ##
+
+class TestPreview(unittest.TestCase):
+ def setUp(self):
+ if __package__ is None or __package__ == '': # direct call or local discovery
+ importlib.import_module('load_nef', __package__).get()
+ else: # parent discovery
+ importlib.import_module('.load_nef', __package__).get()
+
+ def test_construct(self):
+ preview = Preview()
+ self.assertIsInstance(preview, Preview)
+ self.assertEqual(len(preview._children), 3)
+
+ def test_call(self):
+ preview = Preview()
+ # call raises error if file cannot be read
+ self.assertRaises(errors.ReaderError, preview,
+ os.path.join(os.path.dirname(__file__), 'missing.jpg'))
+ self.assertRaises(errors.ReaderError, preview(
+ os.path.join(os.path.dirname(__file__), 'invalid.jpg')), 10)
+ self.assertRaises(errors.UnsupportedFileFormatError, preview,
+ os.path.join(os.path.dirname(__file__), 'invalid.foo'))
+
+ # call returns raw preview
+ gen = preview(os.path.join(os.path.dirname(__file__), 'testimage.nef'))
+ img = gen(10)
+ self.assertIsInstance(img, PIL.Image.Image)
+ self.assertEqual(img.size, (10, 8))
+ self.assertEqual(sum(band for pix in img.getdata() for band in pix), 25287)
+ img.close()
+
+ # call returns jpeg image
+ gen = preview(os.path.join(os.path.dirname(__file__), 'testimage.jpg'))
+ img = gen(10)
+ self.assertIsInstance(img, PIL.Image.Image)
+ self.assertEqual(img.size, (10, 10))
+ self.assertEqual(sum(band for pix in img.getdata() for band in pix), 0)
+ img.close()
+
+ # preview generator can also extract data from non-image files
+ gen = preview(os.path.join(os.path.dirname(__file__), 'testfile.pdf'))
+ img = gen(10)
+ self.assertIsInstance(img, PIL.Image.Image)
+ self.assertEqual(img.size, (8, 10))
+ self.assertEqual(sum(img.getdata()), 20258)
+ img.close()
+
+
+## main ##
+
+if __name__ == '__main__':
+ unittest.main()
+
+## EOF ##
diff --git a/test/reader/preview/test_rawpy.py b/test/reader/preview/test_rawpy.py
new file mode 100644
index 0000000..ed35f53
--- /dev/null
+++ b/test/reader/preview/test_rawpy.py
@@ -0,0 +1,59 @@
+"""
+
+Part of the bsie test suite.
+A copy of the license is provided with the project.
+Author: Matthias Baumgartner, 2022
+"""
+# standard imports
+from functools import partial
+import importlib
+import os
+import unittest
+
+# external imports
+import PIL.Image
+
+# bsie imports
+from bsie.utils import errors
+
+# objects to test
+from bsie.reader.preview._rawpy import RawpyPreviewReader
+
+
+## code ##
+
+class TestRawpyPreviewReader(unittest.TestCase):
+ def setUp(self):
+ if __package__ is None or __package__ == '': # direct call or local discovery
+ importlib.import_module('load_nef', __package__).get()
+ else: # parent discovery
+ importlib.import_module('.load_nef', __package__).get()
+
+ def test_call(self):
+ rdr = RawpyPreviewReader()
+ # raises exception when image cannot be read
+ self.assertRaises(errors.ReaderError, rdr,
+ os.path.join(os.path.dirname(__file__), 'invalid.nef'))
+ # raises exception when image has invalid type
+ self.assertRaises(errors.UnsupportedFileFormatError, rdr,
+ os.path.join(os.path.dirname(__file__), 'invalid.jpg'))
+ self.assertRaises(errors.UnsupportedFileFormatError, rdr,
+ os.path.join(os.path.dirname(__file__), 'invalid.foo'))
+ # proper file produces a generator
+ gen = rdr(os.path.join(os.path.dirname(__file__), 'testimage.nef'))
+ self.assertIsInstance(gen, partial)
+ # generator produces an image
+ img = gen(10)
+ self.assertIsInstance(img, PIL.Image.Image)
+ self.assertEqual(img.size, (10, 7))
+ self.assertEqual(sum(band for pix in img.getdata() for band in pix), 15269)
+ # cleanup
+ img.close()
+
+
+## main ##
+
+if __name__ == '__main__':
+ unittest.main()
+
+## EOF ##
diff --git a/test/reader/preview/test_utils.py b/test/reader/preview/test_utils.py
new file mode 100644
index 0000000..c10c38c
--- /dev/null
+++ b/test/reader/preview/test_utils.py
@@ -0,0 +1,44 @@
+"""
+
+Part of the bsie test suite.
+A copy of the license is provided with the project.
+Author: Matthias Baumgartner, 2022
+"""
+# standard imports
+import os
+import unittest
+
+# external imports
+import PIL.Image
+
+# objects to test
+from bsie.reader.preview.utils import resize
+
+
+## code ##
+
+class TestUtils(unittest.TestCase):
+
+ def test_resize(self):
+ img = PIL.Image.open(os.path.join(os.path.dirname(__file__), 'testimage.jpg'))
+ landscape = img.resize((100, 80))
+ portrait = img.resize((80, 100))
+ self.assertEqual(img.size, (100, 100))
+ self.assertEqual(landscape.size, (100, 80))
+ self.assertEqual(portrait.size, (80, 100))
+ # resize can downscale
+ self.assertEqual(resize(img, 10).size, (10, 10))
+ self.assertEqual(resize(img, 20).size, (20, 20))
+ # resize can upscale
+ self.assertEqual(resize(img, 200).size, (200, 200))
+ # aspect ratio is preserved
+ self.assertEqual(resize(landscape, 10).size, (10, 8))
+ self.assertEqual(resize(portrait, 10).size, (8, 10))
+
+
+## main ##
+
+if __name__ == '__main__':
+ unittest.main()
+
+## EOF ##
diff --git a/test/reader/preview/testfile.pdf b/test/reader/preview/testfile.pdf
new file mode 100644
index 0000000..592d448
--- /dev/null
+++ b/test/reader/preview/testfile.pdf
Binary files differ
diff --git a/test/reader/preview/testimage.jpg b/test/reader/preview/testimage.jpg
new file mode 100644
index 0000000..4c2aca5
--- /dev/null
+++ b/test/reader/preview/testimage.jpg
Binary files differ