diff options
-rw-r--r-- | .gitignore | 1 | ||||
-rw-r--r-- | bsie/reader/preview/__init__.py | 39 | ||||
-rw-r--r-- | bsie/reader/preview/_pg.py | 86 | ||||
-rw-r--r-- | bsie/reader/preview/_pillow.py | 44 | ||||
-rw-r--r-- | bsie/reader/preview/_rawpy.py | 66 | ||||
-rw-r--r-- | bsie/reader/preview/utils.py | 39 | ||||
-rw-r--r-- | setup.py | 1 | ||||
-rw-r--r-- | test/reader/preview/__init__.py | 0 | ||||
-rw-r--r-- | test/reader/preview/invalid.foo | 0 | ||||
-rw-r--r-- | test/reader/preview/invalid.jpg | 0 | ||||
-rw-r--r-- | test/reader/preview/load_nef.py | 28 | ||||
-rw-r--r-- | test/reader/preview/test_pg.py | 82 | ||||
-rw-r--r-- | test/reader/preview/test_pillow.py | 50 | ||||
-rw-r--r-- | test/reader/preview/test_preview.py | 77 | ||||
-rw-r--r-- | test/reader/preview/test_rawpy.py | 59 | ||||
-rw-r--r-- | test/reader/preview/test_utils.py | 44 | ||||
-rw-r--r-- | test/reader/preview/testfile.pdf | bin | 0 -> 7295 bytes | |||
-rw-r--r-- | test/reader/preview/testimage.jpg | bin | 0 -> 6476 bytes |
18 files changed, 616 insertions, 0 deletions
@@ -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 ## @@ -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 Binary files differnew file mode 100644 index 0000000..592d448 --- /dev/null +++ b/test/reader/preview/testfile.pdf diff --git a/test/reader/preview/testimage.jpg b/test/reader/preview/testimage.jpg Binary files differnew file mode 100644 index 0000000..4c2aca5 --- /dev/null +++ b/test/reader/preview/testimage.jpg |