diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..0fa1a54 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,28 @@ +# .coveragerc to control coverage.py +[run] +branch = True +source = kubi +# omit = bad_file.py + +[paths] +source = + src/ + */site-packages/ + +[report] +# Regexes for lines to exclude from consideration +exclude_lines = + # Have to re-enable the standard pragma + pragma: no cover + + # Don't complain about missing debug-only code: + def __repr__ + if self\.debug + + # Don't complain if tests don't hit defensive assertion code: + raise AssertionError + raise NotImplementedError + + # Don't complain if non-runnable code isn't run: + if 0: + if __name__ == .__main__.: diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4c63b7f --- /dev/null +++ b/.gitignore @@ -0,0 +1,58 @@ +# Temporary and binary files +*~ +*.py[cod] +*.so +*.cfg +!.isort.cfg +!setup.cfg +*.orig +*.log +*.pot +__pycache__/* +.cache/* +.*.swp +*/.ipynb_checkpoints/* +.DS_Store + +# Project files +.ropeproject +.project +.pydevproject +.settings +.idea +.vscode +tags + +# Package files +*.egg +*.eggs/ +.installed.cfg +*.egg-info + +# Unittest and coverage +htmlcov/* +.coverage +.coverage.* +.tox +junit*.xml +coverage.xml +.pytest_cache/ + +# Build and docs folder/files +build/* +dist/* +sdist/* +docs/api/* +docs/_rst/* +docs/_build/* +cover/* +MANIFEST + +# Per-project virtualenvs +.venv*/ +.conda*/ + +docs +htmlcov +tests/in +tests/out \ No newline at end of file diff --git a/AUTHORS.rst b/AUTHORS.rst new file mode 100644 index 0000000..df1144f --- /dev/null +++ b/AUTHORS.rst @@ -0,0 +1,5 @@ +============ +Contributors +============ + +* Keim, Stefan diff --git a/CHANGELOG.rst b/CHANGELOG.rst new file mode 100644 index 0000000..7f98a3c --- /dev/null +++ b/CHANGELOG.rst @@ -0,0 +1,10 @@ +========= +Changelog +========= + +Version 0.1 "Bowman" +=========== + +- Basic functionality implemented +- Basic tests implemented (98% coverage | 188run | 0 missing | 2 excluded | 5 partial) +- Simple Benchmarks diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..508e640 --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2021 Keim, Stefan + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..5615880 --- /dev/null +++ b/README.md @@ -0,0 +1,110 @@ +# kubi + +**kubi** is a fast and flexible cubemap generator based on [libvips](https://libvips.github.io/libvips/) + +## Install + +``` shell +pip install git+https://github.com/indus/kubi.git +``` +### Requirements +- [numpy](https://numpy.org/) +- [pyvips](https://libvips.github.io/pyvips/) (bindings for libvips) + +## Description + +**kubi** can convert equirectangular images into a variety of common [layouts](#layouts). All image formats supported by vips (JPEG, PNG, TIFF, WEBP, HEIF, ...) can be used for input and output. With the [DZ format](https://libvips.github.io/libvips/API/current/Making-image-pyramids.md.html) of libvips it is even possible to [create tiled images](#tiled-cubemaps) of the cubefaces. +When used with a glob pattern for multiple input files, **kubi** generates an [index file](https://libvips.github.io/libvips/API/current/libvips-resample.html#vips-mapim) once and reuses it while processing all found images. This can lead to a [significant speedup](#multiple-input-files). + +## Usage +### all options +``` shell +kubi -h +``` + +### basic usage +``` shell +kubi [-s ] [-l {row,column,crossL,crossR,crossH}] srcfile [dstfile] +``` + +#### Layouts + +| none | row | column | +| :---: | :---: | :---: | +| ![none](./img/kubi_layouts_none.png "none") | ![row](./img/kubi_layouts_row.png "row") | ![column](./img/kubi_layouts_column.png "column") | + +| ![crossL](./img/kubi_layouts_crossL.png "crossL") | ![crossR](./img/kubi_layouts_crossR.png "crossR") | ![crossH](./img/kubi_layouts_crossH.png "crossH") | +| :---: | :---: | :---: | +| crossL | crossR | crossH | + +### tiled cubemaps +``` shell +kubi -s 2048 -co tile_size=512 -co depth=onetile -co overlap=0 -co layout=google -co suffix=.jpg[Q=75] -n l r u d f b srcfile dstfile.dz +``` +| argument | explanation | +| :--- | :--- | +| ```-s 2048``` | every cubeface has an overall size of 2048px | +| ```-co tile_size=512``` | every cubeface gets split into tiles with a size of 512px (≙ 3 levels) | +| ```-co depth=onetile``` | stop tiling with onetile (vips default is one pixel!) | +| ```-co overlap=0``` | tiles should have an overlap of 0px (vips default is one pixel!) | +| ```-co layout=google``` | use the google folder/file layout (options are ```dz```, ```zoomify```, ```google```, ```iiif```) | +| ```-co suffix=.jpg[Q=75]```| tiles should be JPEG with a quality of 75 | +| ```-f r l u d f b``` | defined suffixes r(ight), l(eft), u(p), ... | +| ```srcfile``` | the input file; could be a glob pattern | +| ```dstfile.dz``` | the output folder name; use ```.dz``` extension for tiles | + +With some fiddling the file and folder structure could be made compatiple to 360° image viewers. +The above would work with [Marzipano](https://www.marzipano.net/): +``` JS +Marzipano.ImageUrlSource.fromString("/dstfile_{f}/{z}/{y}/{x}.jpg"); +``` + +## Benchmark + +***system:*** CPU: i7-6700 CPU @ 3.40GHz, 4 cores; MEM: 64GB; OS: Win 10 + +### single input file +***input:*** equirectangular image with 4096x2048px +***output:*** cubemap with a cross layout + +| face size | 1024px | 2048px | 4096px | +| ---| --- | --- | --- | +| kubi | 0.9s | 1.7s | 4.9s | +| [py360convert](https://pypi.org/project/py360convert/) | 2.5s | 8.7s | 33.0s | +| *any others ?* | - | - | - | + +### single input file - tiled output +***input:*** equirectangular image with 4096x2048px +***output:*** cubemap as seperate tiles + +| face size | 1024px | 2048px | 4096px | +| ---| --- | --- | --- | +| kubi | 0.9s | 1.4s | 3.5s | +| [panorama_windows.exe](https://github.com/blackironj/panorama) | 1.4s | 4.4s | 16.8s | +| *any others ?* | - | - | - | + +### multiple input files +If only a few cubemaps are needed, performance is probably of minor concern. But with **kubi** it should also be possible to process thousands of animation frames in a reasonable time: + +***input:*** multiple equirectangular images with 4096x2048px +***output:*** multiple cubemaps with a cross layout and a face size of 2048px +| count | total time | time per cubemap | +| :---: | :---: | :---: | +| 1 | 1.7s | 1.7s | +| 2 | 2.6s | 1.3s | +| 3 | 3.5s | 1.2s | +| 5 | 5.2s | 1.0s | +| 10 | 9.9s | 1.0s | +| 20 | 18.5s | 0.9s | + +# Transforms +In addition to regular cubemaps, two optimized mappings can be generated: +- **Equi-Angular Cubemap (EAC)**; C.Brown (2017): [Bringing pixels front and center in VR video](https://blog.google/products/google-ar-vr/bringing-pixels-front-and-center-vr-video/) +- **Optimized Tangens Cubemap (OTC)**; M.Zucker & Y.Higashi (2018): [Cube-to-sphere Projections for Procedural Texturing and Beyond](http://jcgt.org/published/0007/02/01/paper.pdf) (Ch. 3.2 & Ch. 5) + +Both transforms significantly reduce the distortion of the cubemap and thus optimize the pixel yield. However, support in other tools and libraries is rather scarce. + +| deviation | ltr: regular cubemap, EAC, OTC | +| :---: | :---: | +| area | ![error_area](./img/error_area.png "error_area") +| distance | ![error_distance](./img/error_distance.png "error_distance") \ No newline at end of file diff --git a/img/error_area.png b/img/error_area.png new file mode 100644 index 0000000..b36236a Binary files /dev/null and b/img/error_area.png differ diff --git a/img/error_distance.png b/img/error_distance.png new file mode 100644 index 0000000..ab73120 Binary files /dev/null and b/img/error_distance.png differ diff --git a/img/kubi_layouts_column.png b/img/kubi_layouts_column.png new file mode 100644 index 0000000..4ae3b72 Binary files /dev/null and b/img/kubi_layouts_column.png differ diff --git a/img/kubi_layouts_crossH.png b/img/kubi_layouts_crossH.png new file mode 100644 index 0000000..070df27 Binary files /dev/null and b/img/kubi_layouts_crossH.png differ diff --git a/img/kubi_layouts_crossL.png b/img/kubi_layouts_crossL.png new file mode 100644 index 0000000..abf7c7b Binary files /dev/null and b/img/kubi_layouts_crossL.png differ diff --git a/img/kubi_layouts_crossR.png b/img/kubi_layouts_crossR.png new file mode 100644 index 0000000..3d7434e Binary files /dev/null and b/img/kubi_layouts_crossR.png differ diff --git a/img/kubi_layouts_none.png b/img/kubi_layouts_none.png new file mode 100644 index 0000000..e7c6fb6 Binary files /dev/null and b/img/kubi_layouts_none.png differ diff --git a/img/kubi_layouts_row.png b/img/kubi_layouts_row.png new file mode 100644 index 0000000..7454691 Binary files /dev/null and b/img/kubi_layouts_row.png differ diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..fbbc045 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,128 @@ +# This file is used to configure your project. +# Read more about the various options under: +# http://setuptools.readthedocs.io/en/latest/setuptools.html#configuring-setup-using-setup-cfg-files + +[metadata] +name = kubi +description = cubemap generator +author = Keim, Stefan +license = MIT +long_description = file: README.md +long_description_content_type = text/markdown; charset=UTF-8 +url = https://github.com/pyscaffold/pyscaffold/ +# Add here related links, for example: +project_urls = + Documentation = https://github.com/indus/kubi/ + Source = https://github.com/indus/kubi/ + Changelog = https://github.com/indus/kubi/blob/main/CHANGELOG.rst + Tracker = https://github.com/indus/kubi/issues +# Conda-Forge = https://anaconda.org/conda-forge/pyscaffold + Download = https://github.com/indus/kubi/releases/ +# Twitter = https://twitter.com/PyScaffold + +# Change if running only on Windows, Mac or Linux (comma-separated) +platforms = any + +# Add here all kinds of additional classifiers as defined under +# https://pypi.python.org/pypi?%3Aaction=list_classifiers +classifiers = + Development Status :: 4 - Beta + Programming Language :: Python + + +[options] +zip_safe = False +packages = find_namespace: +include_package_data = True +package_dir = + =src + +# Require a min/specific Python version (comma-separated conditions) +# python_requires = >=3.8 + +# Add here dependencies of your project (line-separated), e.g. requests>=2.2,<3.0. +# Version specifiers like >=2.2,<3.0 avoid problems due to API changes in +# new major versions. This works if the required packages follow Semantic Versioning. +# For more information, check out https://semver.org/. +install_requires = + importlib-metadata; python_version<"3.8" + pyvips + numpy + + +[options.packages.find] +where = src +exclude = + tests + +[options.extras_require] +# Add here additional requirements for extra features, to install with: +# `pip install kubi[PDF]` like: +# PDF = ReportLab; RXP + +# Add here test requirements (semicolon/line-separated) +testing = + setuptools + pytest + pytest-cov + +[options.entry_points] +# Add here console scripts like: +# console_scripts = +# script_name = kubi.module:function +# For example: +console_scripts = + kubi = kubi.kubi:run +# And any other entry points, for example: +# pyscaffold.cli = +# awesome = pyscaffoldext.awesome.extension:AwesomeExtension + +[tool:pytest] +# Specify command line options as you would do when invoking pytest directly. +# e.g. --cov-report html (or xml) for html/xml output or --junitxml junit.xml +# in order to write a coverage file that can be read by Jenkins. +# CAUTION: --cov flags may prohibit setting breakpoints while debugging. +# Comment those flags to avoid this py.test issue. +addopts = + --cov kubi --cov-report term-missing + --verbose +norecursedirs = + dist + build + .tox +testpaths = tests +# Use pytest markers to select/deselect specific tests +# markers = +# slow: mark tests as slow (deselect with '-m "not slow"') +# system: mark end-to-end system tests + +[bdist_wheel] +# Use this option if your package is pure-python +universal = 1 + +[devpi:upload] +# Options for the devpi: PyPI server and packaging tool +# VCS export must be deactivated since we are using setuptools-scm +no_vcs = 1 +formats = bdist_wheel + +[flake8] +# Some sane defaults for the code style checker flake8 +max_line_length = 88 +extend_ignore = E203, W503 +# ^ Black-compatible +# E203 and W503 have edge cases handled by black +exclude = + .tox + build + dist + .eggs + docs/conf.py + +[pyscaffold] +# PyScaffold's parameters when the project was created. +# This will be used when updating. Do not change! +version = 4.0.1 +package = kubi +extensions = + no_pyproject diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..1590162 --- /dev/null +++ b/setup.py @@ -0,0 +1,21 @@ +""" + Setup file for kubi. + Use setup.cfg to configure your project. + + This file was generated with PyScaffold 4.0.1. + PyScaffold helps you to put up the scaffold of your new Python project. + Learn more under: https://pyscaffold.org/ +""" +from setuptools import setup + +if __name__ == "__main__": + try: + setup(use_scm_version={"version_scheme": "no-guess-dev"}) + except: # noqa + print( + "\n\nAn error occurred while building the project, " + "please ensure you have the most updated version of setuptools, " + "setuptools_scm and wheel with:\n" + " pip install -U setuptools setuptools_scm wheel\n\n" + ) + raise diff --git a/src/kubi/__init__.py b/src/kubi/__init__.py new file mode 100644 index 0000000..e451f10 --- /dev/null +++ b/src/kubi/__init__.py @@ -0,0 +1,16 @@ +import sys + +if sys.version_info[:2] >= (3, 8): + # TODO: Import directly (no need for conditional) when `python_requires = >= 3.8` + from importlib.metadata import PackageNotFoundError, version # pragma: no cover +else: + from importlib_metadata import PackageNotFoundError, version # pragma: no cover + +try: + # Change here if project is renamed and does not equal the package name + dist_name = __name__ + __version__ = version(dist_name) +except PackageNotFoundError: # pragma: no cover + __version__ = "unknown" +finally: + del version, PackageNotFoundError diff --git a/src/kubi/kubi.py b/src/kubi/kubi.py new file mode 100644 index 0000000..a834062 --- /dev/null +++ b/src/kubi/kubi.py @@ -0,0 +1,330 @@ +""" +Run ``pip install .`` (or ``pip install -e .`` for editable mode) +which will install the command ``kubi`` inside your current environment. +""" + +import argparse +import logging +import sys +import os +import glob +import numpy as np +from numpy import pi + +from kubi import __version__ + +__author__ = "Keim, Stefan" +__copyright__ = "Keim, Stefan" +__license__ = "MIT" + +_logger = logging.getLogger(__name__) + + +# ---- Python API ---- +# The functions defined in this section can be imported by users in their +# Python scripts/interactive interpreter, e.g. via +# `from kubi.kubi import kubi`, +# when using this Python module as a library. + + +def kubi(args): + os.environ['PATH'] = 'C:/Program Files/vips-dev-8.10/bin' + ';' + os.environ['PATH'] + + if args.vips: + os.environ['PATH'] = args.vips + ';' + os.environ['PATH'] + + import pyvips + + src_names = None + if args.ii == None: + _logger.info('Generating index') + + size = -1 + if args.size: + size = args.size + elif args.src: + src_names = glob.glob(args.src,recursive=True) + src_count = len(src_names) + src_multi = src_count > 1 + + if src_count == 0: + print(f'{args.src}: No such file or directory') + return + for name in src_names: + image = pyvips.Image.new_from_file(name) + size = max(size, int(image.width / 4)) + + + ### TODO replace numpy with vips + + ls = np.linspace(-1, 1, size, dtype="f4", endpoint=False) + + if args.transform == "eac": # C.Brown (2017): Bringing pixels front and center in VR video + ls = np.tan(ls / 4 * pi) + elif args.transform == "otc": # M.Zucker & Y.Higashi (2018): Cube-to-sphere Projections for Procedural Texturing and Beyond + ls = np.tan(ls * 0.8687) / np.tan(0.8687) + + xv,yv = np.meshgrid(ls, ls) + + xv2 = xv ** 2 + yv2 = yv ** 2 + + idx = np.stack([ + np.arctan(xv), #tha0 + np.arctan2(yv, np.sqrt(1 + xv2)), #phi0 + np.arctan2(xv, yv), #tha1 + np.arctan2(1, np.sqrt(yv2 + xv2)) #phi1 + ], axis=-1) + + ### end of numpy + + ls = xv = yv = xv2 = yv2 = None + + idx = pyvips.Image.new_from_memory(idx.reshape(size**2 * 4), size, size, 4, 'float') / (pi/2) + + idx = [ + pyvips.Image.bandjoin(idx[0]+3,idx[1]+1), + pyvips.Image.bandjoin(idx[0]+1,idx[1]+1), + pyvips.Image.bandjoin((idx[2]-2)%4,1-idx[3]), + pyvips.Image.bandjoin((4-idx[2])%4,1+idx[3]), + pyvips.Image.bandjoin(idx[0]+2,idx[1]+1), + pyvips.Image.bandjoin(idx[0]%4,idx[1]+1), + ] + + if args.layout is None or args.layout in ("column","row"): + if args.inverse is not None: + for f in range(6): + idx[f] = idx[f].rot180() if args.inverse == 'both' else idx[f].flip(args.inverse) + + if args.layout is None: + index = None + idxA = idx + else: + index = pyvips.Image.arrayjoin(idx, across=6 if args.layout == "row" else 1) + else: + s0 = 0 + s1 = size + s2 = s1*2 + s3 = s1*3 + s4 = s1*4 + + if args.layout == "crossL": + index = pyvips.Image.black(s4, s3, bands = 2) + index -= 1.0 + index = index.insert(idx[1],s0,s1) + index = index.insert(idx[4],s1,s1) + index = index.insert(idx[0],s2,s1) + index = index.insert(idx[5],s3,s1) + index = index.insert(idx[2],s1,s0) + index = index.insert(idx[3],s1,s2) + elif args.layout == "crossR": + index = pyvips.Image.black(s4, s3, bands = 2) + index -= 1.0 + index = index.insert(idx[5],s0,s1) + index = index.insert(idx[1],s1,s1) + index = index.insert(idx[4],s2,s1) + index = index.insert(idx[0],s3,s1) + index = index.insert(idx[2],s2,s0) + index = index.insert(idx[3],s2,s2) + elif args.layout == "crossH": + index = pyvips.Image.black(s3, s4, bands = 2) + index -= 1.0 + index = index.insert(idx[1],s0,s1) + index = index.insert(idx[4],s1,s1) + index = index.insert(idx[0],s2,s1) + index = index.insert(idx[5].rot180(),s1,s3) + index = index.insert(idx[2],s1,s0) + index = index.insert(idx[3],s1,s2) + + if args.inverse is not None: + index = index.rot180() if args.inverse == 'both' else index.flip(args.inverse) + + if args.io is not None: + idx = idx[0].bandjoin(idx[1:6]) if index is None else index + idx.tiffsave(args.io, compression="lzw", predictor="float") + + else: #args.ii != None + _logger.info(f'Reading index: {args.ii}') + + idx = pyvips.Image.tiffload(args.ii) + if idx.bands == 12: + index = None + idxA = [idx[0:2], idx[2:4], idx[4:6], idx[6:8], idx[8:10], idx[10:12]] + else: + index = idx + + idx = None + + if args.src: + if src_names is None: + src_names = glob.glob(args.src, recursive=True) + src_count = len(src_names) + src_multi = src_count > 1 + + dst_suffix = '_'+ args.transform + dst_folder = dst_name = dst_ext = None + + # define dst defaults for single and multi src + if args.dst: + name_split = os.path.splitext(os.path.basename(args.dst)) + dst_name = name_split[0] + dst_suffix = f'_{name_split[0]}' if name_split[0] != '' and src_multi else '' + dst_ext = name_split[1] + dst_folder = os.path.dirname(args.dst) + dst_folder = dst_folder if dst_folder else '.' + + if not os.path.exists(dst_folder): + os.makedirs(dst_folder) + + interp = pyvips.vinterpolate.Interpolate.new(args.resample) + + # START LOOP on src files + for name in src_names: + img = pyvips.Image.new_from_file(name) + name_split = os.path.splitext(os.path.basename(name)) + + if not dst_folder: + dst_folder = os.path.dirname(name) + dst_folder = dst_folder if dst_folder else '.' + + if not dst_name or src_multi: + dst_name = name_split[0] + + if not dst_ext: + dst_ext = name_split[1] + + dst = f'{dst_folder}/{dst_name}{dst_suffix}' + + fac = img.width/4 + + + if index is None: + for f in range(6): + idx = idxA[f]*fac + fn = args.facenames[f] if args.facenames is not None else str(f) + mapim = img.mapim(idx, interpolate=interp) + mapim.write_to_file(f'{dst}_{fn}{dst_ext}', **args.co) + else: + idx = index*fac + mapim = img.mapim(idx, interpolate=interp) + mapim.write_to_file(f'{dst}{dst_ext}', **args.co) + + + +# ---- CLI ---- +# The functions defined in this section are wrappers around the main Python +# API allowing them to be called directly from the terminal as a CLI +# executable/script. + + +def parse_args(args): + parser = argparse.ArgumentParser(description="cubemap generator") + parser.add_argument( + "--version", + action="version", + version="kubi {ver}".format(ver=__version__), + ) + parser.add_argument( + "-v", + "--verbose", + dest="loglevel", + help="set loglevel to INFO", + action="store_const", + const=logging.INFO, + ) + parser.add_argument( + "-q", + "--quite", + dest="loglevel", + help="set loglevel to ERROR", + action="store_const", + const=logging.ERROR, + ) + parser.add_argument(dest="src", help="the input glob pattern", metavar="srcfile", nargs='?' ) + parser.add_argument(dest="dst", help='the output file or folder', metavar="dstfile", nargs='?') + parser.add_argument('-s', '--size ', dest="size", metavar='', type=int, help='the edge size (default 1/4 of max src width)') + parser.add_argument('-t', '--transform', choices=['eac', 'otc'], help=""" + eac: Equi-angular cubemap; + optan: optimized tangent transform + """) + parser.add_argument('-i', '--inverse', choices=['horizontal', 'vertical', 'both'], help="flips the idx") + parser.add_argument('-l', '--layout ', dest="layout", choices=['row', 'column', 'crossL', 'crossR', 'crossH'], help=""" + none: seperate faces (default); + row: +X,-X,+Y,-Y,+Z,-Z; + column: +X,-X,+Y,-Y,+Z,-Z; + crossL: vertical cross with +Y,-Y on the left; + crossR: vertical cross with +Y,-Y on the right; + crossH: horizontal cross; + """) + parser.add_argument('-r', '--resample', default='bilinear', choices=['nearest', 'bilinear', 'bicubic', 'lbb', 'nohalo', 'vsqbs'], help=""" + nearest: nearest-neighbour interpolation; + bilinear: bilinear interpolation (default); + bicubic: bicubic interpolation (Catmull-Rom); + lbb: reduced halo bicubic; + nohalo: edge sharpening resampler with halo reduction; + vsqbs: B-Splines with antialiasing smoothing + """) + parser.add_argument('-f', '--facenames', metavar="", nargs=6 ,help='suffixes for +X, -X, +Y, -Y, +Z, -Z (e.g. -n r l u d f b)') + parser.add_argument('-co', dest='co', metavar='*', action='append', help='create options (more info in the epilog)') + parser.add_argument('--vips', help='path to the VIPS bin directory (usefull if VIPS is not added to PATH; e.g. on Windows)') + parser.add_argument('--io', dest='io', help='index file output', metavar='dstindex') + parser.add_argument('--ii', dest='ii', help='index file input', metavar='srcindex') + + args = parser.parse_args(args) + + if args.src is None: + if args.io is None: + parser.print_usage(sys.stderr) + print(f"{__name__}: error: 'srcfile' or 'dstindex' has to be set") + return + elif args.size is None: + parser.print_usage(sys.stderr) + print(f"{__name__}: error: to write 'dstindex' without 'src' you have to set 'size' (-s)") + return + + if args.ii is not None and not (all(v is None for v in [args.size,args.transform,args.inverse,args.layout])): + parser.print_usage(sys.stderr) + print(f"{__name__}: error: 'size', 'transform', 'flip' and 'layout' is already baked into the 'srcindex'; please remove the arguments") + return + + if args.transform is None: + args.transform='cubemap' + + coDict = {} + if args.co: + for co in args.co: + cosp = co.split("=", 1) + if len(cosp) == 2: + coDict[cosp[0]] = int(cosp[1]) if cosp[1].isnumeric() else cosp[1] + else: + coDict['compression'] = cosp[0] + args.co = coDict + + return args + + +def setup_logging(loglevel): + logging.getLogger("pyvips").setLevel(logging.ERROR) + logformat = "[%(asctime)s] %(levelname)s:%(name)s:%(message)s" + logging.basicConfig( + level=loglevel, stream=sys.stdout, format=logformat, datefmt="%Y-%m-%d %H:%M:%S" + ) + + +def main(args): + args = parse_args(args) + if args is None: return + setup_logging(args.loglevel) + kubi(args) + + +def run(): + main(sys.argv[1:]) + + +if __name__ == "__main__": + # ^ This is a guard statement that will prevent the following code from + # being executed in the case someone imports this file instead of + # executing it as a script. + # https://docs.python.org/3/library/__main__.html + run() diff --git a/tests/bench.bat b/tests/bench.bat new file mode 100644 index 0000000..da2e57b --- /dev/null +++ b/tests/bench.bat @@ -0,0 +1,8 @@ +hyperfine.exe -r 5 -L s 1024,2048,4096 ^ +"kubi -s {s} -co lzw -l crossL tests\in\basemap.tif tests\out\cross_kubi_{s}.tif" "python tests\in\convert360.py --convert e2c --w {s} --i tests\in\basemap.tif --o tests\out\cross_convert360_{s}.tif" + +::hyperfine.exe -r 5 -L s 1024,2048,4096 ^ +::"kubi -s {s} .\tests\in\basemap.jpg .\tests\out\kubi_{s}\basemap" ".\tests\in\panorama_windows.exe -l {s} -i .\tests\in\basemap.jpg -o .\tests\out\panorama_{s}" + +::hyperfine.exe -r 3 -L x 1,2,3,5,10,20 ^ +::"kubi -s 2048 -co lzw -x {x} -l crossL tests\in\basemap*.tif tests\out\cross_kubi_{x}.tif" \ No newline at end of file diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..ec19acd --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,48 @@ +""" + Dummy conftest.py for kubi. + + If you don't know what this is for, just leave it empty. + Read more about conftest.py under: + - https://docs.pytest.org/en/stable/fixture.html + - https://docs.pytest.org/en/stable/writing_plugins.html +""" + +# import pytest +import os +import glob +import shutil +import urllib.request + +path_in = './tests/in/' +path_out = './tests/out/' + +def pytest_configure(config): + # remove output folder + if not os.path.exists(path_out): + os.makedirs(path_out) + + if os.path.exists(path_out+'/sub'): + shutil.rmtree(path_out+'/sub') + + if os.path.exists(path_out+'/tiled'): + shutil.rmtree(path_out+'/tiled') + + for f in glob.glob(path_out+'*', recursive=True): + os.remove(f) + + # create input folder + if not os.path.exists(path_in): + os.makedirs(path_in) + + # download test images + test_images= { + "basemap.tif": "https://geoservice.dlr.de/eoc/basemap/wms?VERSION=1.1.1&REQUEST=GetMap&SRS=epsg:4326&BBOX=-180,-90,180,90&WIDTH=4096&HEIGHT=2048&FORMAT=image/geotiff&LAYERS=basemap", + "baseoverlay.tif": "https://geoservice.dlr.de/eoc/basemap/wms?VERSION=1.1.1&REQUEST=GetMap&SRS=epsg:4326&BBOX=-180,-90,180,90&WIDTH=3072&HEIGHT=1536&FORMAT=image/geotiff&TRANSPARENT=true&LAYERS=baseoverlay", + } + + for file in test_images.items(): + if not os.path.exists(path_in + file[0]): + with urllib.request.urlopen(file[1]) as response, open(path_in + file[0], 'wb') as out_file: + shutil.copyfileobj(response, out_file) + + shutil.copyfile(path_in + "basemap.tif", path_out + "bm.tif") \ No newline at end of file diff --git a/tests/test_kubi.py b/tests/test_kubi.py new file mode 100644 index 0000000..5090d85 --- /dev/null +++ b/tests/test_kubi.py @@ -0,0 +1,179 @@ +import pytest + +from kubi.kubi import main, run, parse_args +import os +import sys +import glob + +os.environ['PATH'] = 'C:/Program Files/vips-dev-8.10/bin' + ';' + os.environ['PATH'] + +import pyvips + +__author__ = "Keim, Stefan" +__copyright__ = "Keim, Stefan" +__license__ = "MIT" + +path_in = './tests/in/' +path_out = './tests/out/' + + + +def test_parse_args(capsys): + + args0 = parse_args(['srcfile']) + assert args0.co != None + + args1 = parse_args(['-co', 'compression=lzw', 'srcfile']) + assert args1.co['compression'] == 'lzw' + + args2 = parse_args(['-co', 'lzw', '-co', 'predictor=horizontal', 'srcfile']) + assert args2.co['compression'] == 'lzw' and args2.co['predictor'] == 'horizontal' + + parse_args([]) + captured = capsys.readouterr() + assert "error: 'srcfile' or 'dstindex' has to be set" in captured.out + + parse_args(['--io', 'nosize']) + captured = capsys.readouterr() + assert "error: to write 'dstindex' without 'src' you have to set 'size' (-s)" in captured.out + + parse_args(['--ii', 'nosize', '-s', '999', 'somesrc']) + captured = capsys.readouterr() + assert "error: 'size', 'transform', 'flip' and 'layout' is already baked into the 'srcindex'; please remove the arguments" in captured.out + + +def test_run(capsys): + + args = ['', '-v','--vips','some/path/to/vips/bin', 'does_not_exist.tif'] + print('\nargs: '+' '.join(args)) + sys.argv = args + run() + captured = capsys.readouterr() + assert 'does_not_exist.tif: No such file or directory' in captured.out + assert 'some/path/to/vips/bin;' in os.environ['PATH'] + +def test_main_none_io(capsys): + args = ['--io', path_out + 'idx_none', path_in+'basemap.tif', path_out+'basemap_none.jpg'] + print('\nargs: '+' '.join(args)) + main(args) + + dst_names = glob.glob(path_out+"*_none*.jpg") + assert len(dst_names) == 6 + dst0 = pyvips.Image.new_from_file(dst_names[0]) + assert dst0.width == dst0.height == 1024 + assert dst0.bands == 3 + +def test_main_none_ii(capsys): + args = ['--ii', path_out + 'idx_none', path_in+'baseoverlay.tif', path_out+'baseoverlay_none.png'] + print('\nargs: '+' '.join(args)) + main(args) + + dst_names = glob.glob(path_out+"*overlay_none*.png") + assert len(dst_names) == 6 + dst0 = pyvips.Image.new_from_file(dst_names[0]) + assert dst0.width == dst0.height == 1024 + assert dst0.bands == 4 + + +def test_main_none_inplace(capsys): + os.chdir(path_out) + args = ['-s', '256', '-t', 'eac', 'bm.tif'] + print('\nargs: '+' '.join(args)) + main(args) + os.chdir("./../..") + dst_names = glob.glob(path_out+"*eac*.tif") + assert len(dst_names) == 6 + dst0 = pyvips.Image.new_from_file(dst_names[0]) + assert dst0.width == dst0.height == 256 + +def test_main_none_sub(capsys): + args = ['-s', '256', '-t', 'optan', path_out+'bm.tif', path_out+'sub/warped.png'] + print('\nargs: '+' '.join(args)) + main(args) + + dst_names = glob.glob(path_out+"sub/*warped*.png") + assert len(dst_names) == 6 + dst0 = pyvips.Image.new_from_file(dst_names[0]) + assert dst0.width == dst0.height == 256 + +def test_main_column_ii(capsys): + args = ['-l','column', '--io', path_out + 'idx_column', path_in+'basemap.tif', path_out+'basemap_column.png'] + print('\nargs: '+' '.join(args)) + main(args) + + dst_names = glob.glob(path_out+"*_column*.png") + assert len(dst_names) == 1 + dst0 = pyvips.Image.new_from_file(dst_names[0]) + assert dst0.width == 1024 and dst0.height == 1024 * 6 + +def test_main_column_io(capsys): + args = ['--ii', path_out + 'idx_column', path_in+'baseoverlay.tif', path_out+'baseoverlay_column'] + print('\nargs: '+' '.join(args)) + main(args) + + dst_names = glob.glob(path_out+"baseoverlay_column.tif") + assert len(dst_names) == 1 + dst0 = pyvips.Image.new_from_file(dst_names[0]) + assert dst0.width == 1024 and dst0.height == 1024 * 6 + +def test_main_row_multi(capsys): + args = ['-l','row', '-s','512','-i','both','-t','eac', path_in+'base*.tif', path_out+'basemap_row.png'] + print('\nargs: '+' '.join(args)) + main(args) + + dst_names = glob.glob(path_out+"*_row*.png") + assert len(dst_names) == 2 + dst0 = pyvips.Image.new_from_file(dst_names[0]) + assert dst0.width == 512 * 6 and dst0.height == 512 + +def test_main_crossL(capsys): + args = ['-l','crossL','-i','horizontal', path_in+'basemap.tif', path_out+'basemap_crossL.png'] + print('\nargs: '+' '.join(args)) + main(args) + + dst_names = glob.glob(path_out+"*_crossL*.png") + assert len(dst_names) == 1 + dst0 = pyvips.Image.new_from_file(dst_names[0]) + assert dst0.width == 1024 * 4 and dst0.height == 1024 * 3 + +def test_main_crossR(capsys): + args = ['-l','crossR','-t','optan', path_in+'basemap.tif', path_out+'basemap_crossR.png'] + print('\nargs: '+' '.join(args)) + main(args) + + dst_names = glob.glob(path_out+"*_crossR*.png") + assert len(dst_names) == 1 + dst0 = pyvips.Image.new_from_file(dst_names[0]) + assert dst0.width == 1024 * 4 and dst0.height == 1024 * 3 + +def test_main_crossH(capsys): + args = ['-l','crossH', path_in+'basemap.tif', path_out+'basemap_crossH.png'] + print('\nargs: '+' '.join(args)) + main(args) + + dst_names = glob.glob(path_out+"*_crossH*.png") + assert len(dst_names) == 1 + dst0 = pyvips.Image.new_from_file(dst_names[0]) + assert dst0.width == 1024 * 3 and dst0.height == 1024 * 4 + +def test_main_crossH(capsys): + args = ['-l','crossH', path_in+'basemap.tif', path_out+'basemap_crossH.png'] + print('\nargs: '+' '.join(args)) + main(args) + + dst_names = glob.glob(path_out+"*_crossH*.png") + assert len(dst_names) == 1 + dst0 = pyvips.Image.new_from_file(dst_names[0]) + assert dst0.width == 1024 * 3 and dst0.height == 1024 * 4 + +def test_main_tiled(capsys): + args = ['-co','tile_size=512','-co','depth=onetile','-co','overlap=0','-co','suffix=.jpg[Q=75]',path_in+'basemap.tif',path_out+'tiled\dstfile.dz'] + print('\nargs: '+' '.join(args)) + main(args) + + dst_names = glob.glob(path_out+"tiled/**/*", recursive=True) + assert len(dst_names) == 54 + + + + diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..9475952 --- /dev/null +++ b/tox.ini @@ -0,0 +1,68 @@ +# Tox configuration file +# Read more under https://tox.readthedocs.org/ +# THIS SCRIPT IS SUPPOSED TO BE AN EXAMPLE. MODIFY IT ACCORDING TO YOUR NEEDS! + +[tox] +minversion = 3.15 +envlist = default + + +[testenv] +description = invoke pytest to run automated tests +isolated_build = False +setenv = + TOXINIDIR = {toxinidir} +passenv = + HOME +extras = + testing +commands = + pytest {posargs} + + +[testenv:{clean,build}] +description = + Build (or clean) the package in isolation according to instructions in: + https://setuptools.readthedocs.io/en/latest/build_meta.html#how-to-use-it + https://github.com/pypa/pep517/issues/91 + https://github.com/pypa/build +# NOTE: build is still experimental, please refer to the links for updates/issues +skip_install = True +changedir = {toxinidir} +deps = + build: build[virtualenv] +commands = + clean: python -c 'from shutil import rmtree; rmtree("build", True); rmtree("dist", True)' + build: python -m build . +# By default `build` produces wheels, you can also explicitly use the flags `--sdist` and `--wheel` + + +[testenv:{docs,doctests}] +description = invoke sphinx-build to build the docs/run doctests +setenv = + DOCSDIR = {toxinidir}/docs + BUILDDIR = {toxinidir}/docs/_build + docs: BUILD = html + doctests: BUILD = doctest +deps = + -r {toxinidir}/docs/requirements.txt + # ^ requirements.txt shared with Read The Docs +commands = + sphinx-build -b {env:BUILD} -d "{env:BUILDDIR}/doctrees" "{env:DOCSDIR}" "{env:BUILDDIR}/{env:BUILD}" {posargs} + + +[testenv:publish] +description = + Publish the package you have been developing to a package index server. + By default, it uses testpypi. If you really want to publish your package + to be publicly accessible in PyPI, use the `-- --repository pypi` option. +skip_install = True +changedir = {toxinidir} +passenv = + TWINE_USERNAME + TWINE_PASSWORD + TWINE_REPOSITORY +deps = twine +commands = + python -m twine check dist/* + python -m twine upload {posargs:--repository testpypi} dist/*