diff --git a/poetry.lock b/poetry.lock index e8cde52..22a7919 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.8.5 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.1.3 and should not be changed by hand. [[package]] name = "cfgv" @@ -6,6 +6,7 @@ version = "3.4.0" description = "Validate configuration and produce human readable error messages." optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9"}, {file = "cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560"}, @@ -17,6 +18,8 @@ version = "0.4.6" description = "Cross-platform colored terminal text." optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +groups = ["dev"] +markers = "sys_platform == \"win32\"" files = [ {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, @@ -28,6 +31,7 @@ version = "0.3.9" description = "Distribution utilities" optional = false python-versions = "*" +groups = ["dev"] files = [ {file = "distlib-0.3.9-py2.py3-none-any.whl", hash = "sha256:47f8c22fd27c27e25a65601af709b38e4f0a45ea4fc2e710f65755fa8caaaf87"}, {file = "distlib-0.3.9.tar.gz", hash = "sha256:a60f20dea646b8a33f3e7772f74dc0b2d0772d2837ee1342a00645c81edf9403"}, @@ -39,6 +43,8 @@ version = "1.2.2" description = "Backport of PEP 654 (exception groups)" optional = false python-versions = ">=3.7" +groups = ["dev"] +markers = "python_version < \"3.11\"" files = [ {file = "exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b"}, {file = "exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc"}, @@ -53,6 +59,7 @@ version = "3.16.1" description = "A platform independent file lock." optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "filelock-3.16.1-py3-none-any.whl", hash = "sha256:2082e5703d51fbf98ea75855d9d5527e33d8ff23099bec374a134febee6946b0"}, {file = "filelock-3.16.1.tar.gz", hash = "sha256:c249fbfcd5db47e5e2d6d62198e565475ee65e4831e2561c8e313fa7eb961435"}, @@ -61,7 +68,7 @@ files = [ [package.extras] docs = ["furo (>=2024.8.6)", "sphinx (>=8.0.2)", "sphinx-autodoc-typehints (>=2.4.1)"] testing = ["covdefaults (>=2.3)", "coverage (>=7.6.1)", "diff-cover (>=9.2)", "pytest (>=8.3.3)", "pytest-asyncio (>=0.24)", "pytest-cov (>=5)", "pytest-mock (>=3.14)", "pytest-timeout (>=2.3.1)", "virtualenv (>=20.26.4)"] -typing = ["typing-extensions (>=4.12.2)"] +typing = ["typing-extensions (>=4.12.2) ; python_version < \"3.11\""] [[package]] name = "identify" @@ -69,6 +76,7 @@ version = "2.6.4" description = "File identification library for Python" optional = false python-versions = ">=3.9" +groups = ["dev"] files = [ {file = "identify-2.6.4-py2.py3-none-any.whl", hash = "sha256:993b0f01b97e0568c179bb9196391ff391bfb88a99099dbf5ce392b68f42d0af"}, {file = "identify-2.6.4.tar.gz", hash = "sha256:285a7d27e397652e8cafe537a6cc97dd470a970f48fb2e9d979aa38eae5513ac"}, @@ -83,6 +91,7 @@ version = "2.0.0" description = "brain-dead simple config-ini parsing" optional = false python-versions = ">=3.7" +groups = ["dev"] files = [ {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"}, {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, @@ -94,6 +103,7 @@ version = "1.9.1" description = "Node.js virtual environment builder" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +groups = ["dev"] files = [ {file = "nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9"}, {file = "nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f"}, @@ -105,6 +115,8 @@ version = "2.0.2" description = "Fundamental package for array computing in Python" optional = false python-versions = ">=3.9" +groups = ["main", "dev"] +markers = "python_version < \"3.13\"" files = [ {file = "numpy-2.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:51129a29dbe56f9ca83438b706e2e69a39892b5eda6cedcb6b0c9fdc9b0d3ece"}, {file = "numpy-2.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f15975dfec0cf2239224d80e32c3170b1d168335eaedee69da84fbe9f1f9cd04"}, @@ -159,6 +171,8 @@ version = "2.2.1" description = "Fundamental package for array computing in Python" optional = false python-versions = ">=3.10" +groups = ["main", "dev"] +markers = "python_version >= \"3.13\"" files = [ {file = "numpy-2.2.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5edb4e4caf751c1518e6a26a83501fda79bff41cc59dac48d70e6d65d4ec4440"}, {file = "numpy-2.2.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:aa3017c40d513ccac9621a2364f939d39e550c542eb2a894b4c8da92b38896ab"}, @@ -223,6 +237,7 @@ version = "24.2" description = "Core utilities for Python packages" optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759"}, {file = "packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f"}, @@ -234,6 +249,7 @@ version = "11.1.0" description = "Python Imaging Library (Fork)" optional = false python-versions = ">=3.9" +groups = ["main"] files = [ {file = "pillow-11.1.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:e1abe69aca89514737465752b4bcaf8016de61b3be1397a8fc260ba33321b3a8"}, {file = "pillow-11.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c640e5a06869c75994624551f45e5506e4256562ead981cce820d5ab39ae2192"}, @@ -313,7 +329,7 @@ docs = ["furo", "olefile", "sphinx (>=8.1)", "sphinx-copybutton", "sphinx-inline fpx = ["olefile"] mic = ["olefile"] tests = ["check-manifest", "coverage (>=7.4.2)", "defusedxml", "markdown2", "olefile", "packaging", "pyroma", "pytest", "pytest-cov", "pytest-timeout", "trove-classifiers (>=2024.10.12)"] -typing = ["typing-extensions"] +typing = ["typing-extensions ; python_version < \"3.10\""] xmp = ["defusedxml"] [[package]] @@ -322,6 +338,7 @@ version = "4.3.6" description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "platformdirs-4.3.6-py3-none-any.whl", hash = "sha256:73e575e1408ab8103900836b97580d5307456908a03e92031bab39e4554cc3fb"}, {file = "platformdirs-4.3.6.tar.gz", hash = "sha256:357fb2acbc885b0419afd3ce3ed34564c13c9b95c89360cd9563f73aa5e2b907"}, @@ -338,6 +355,7 @@ version = "1.5.0" description = "plugin and hook calling mechanisms for python" optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669"}, {file = "pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1"}, @@ -353,6 +371,7 @@ version = "4.0.1" description = "A framework for managing and maintaining multi-language pre-commit hooks." optional = false python-versions = ">=3.9" +groups = ["dev"] files = [ {file = "pre_commit-4.0.1-py2.py3-none-any.whl", hash = "sha256:efde913840816312445dc98787724647c65473daefe420785f885e8ed9a06878"}, {file = "pre_commit-4.0.1.tar.gz", hash = "sha256:80905ac375958c0444c65e9cebebd948b3cdb518f335a091a670a89d652139d2"}, @@ -371,6 +390,7 @@ version = "8.3.4" description = "pytest: simple powerful testing with Python" optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "pytest-8.3.4-py3-none-any.whl", hash = "sha256:50e16d954148559c9a74109af1eaf0c945ba2d8f30f0a3d3335edde19788b6f6"}, {file = "pytest-8.3.4.tar.gz", hash = "sha256:965370d062bce11e73868e0335abac31b4d3de0e82f4007408d242b4f8610761"}, @@ -393,6 +413,7 @@ version = "3.14.0" description = "Thin-wrapper around the mock package for easier use with pytest" optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "pytest-mock-3.14.0.tar.gz", hash = "sha256:2719255a1efeceadbc056d6bf3df3d1c5015530fb40cf347c0f9afac88410bd0"}, {file = "pytest_mock-3.14.0-py3-none-any.whl", hash = "sha256:0b72c38033392a5f4621342fe11e9219ac11ec9d375f8e2a0c164539e0d70f6f"}, @@ -410,6 +431,7 @@ version = "6.0.2" description = "YAML parser and emitter for Python" optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "PyYAML-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0a9a2848a5b7feac301353437eb7d5957887edbf81d56e903999a75a3d743086"}, {file = "PyYAML-6.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:29717114e51c84ddfba879543fb232a6ed60086602313ca38cce623c1d62cfbf"}, @@ -472,6 +494,8 @@ version = "1.13.1" description = "Fundamental algorithms for scientific computing in Python" optional = false python-versions = ">=3.9" +groups = ["dev"] +markers = "python_version < \"3.13\"" files = [ {file = "scipy-1.13.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:20335853b85e9a49ff7572ab453794298bcf0354d8068c5f6775a0eabf350aca"}, {file = "scipy-1.13.1-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:d605e9c23906d1994f55ace80e0125c587f96c020037ea6aa98d01b4bd2e222f"}, @@ -514,6 +538,8 @@ version = "1.14.1" description = "Fundamental algorithms for scientific computing in Python" optional = false python-versions = ">=3.10" +groups = ["dev"] +markers = "python_version >= \"3.13\"" files = [ {file = "scipy-1.14.1-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:b28d2ca4add7ac16ae8bb6632a3c86e4b9e4d52d3e34267f6e1b0c1f8d87e389"}, {file = "scipy-1.14.1-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:d0d2821003174de06b69e58cef2316a6622b60ee613121199cb2852a873f8cf3"}, @@ -556,7 +582,7 @@ numpy = ">=1.23.5,<2.3" [package.extras] dev = ["cython-lint (>=0.12.2)", "doit (>=0.36.0)", "mypy (==1.10.0)", "pycodestyle", "pydevtool", "rich-click", "ruff (>=0.0.292)", "types-psutil", "typing_extensions"] doc = ["jupyterlite-pyodide-kernel", "jupyterlite-sphinx (>=0.13.1)", "jupytext", "matplotlib (>=3.5)", "myst-nb", "numpydoc", "pooch", "pydata-sphinx-theme (>=0.15.2)", "sphinx (>=5.0.0,<=7.3.7)", "sphinx-design (>=0.4.0)"] -test = ["Cython", "array-api-strict (>=2.0)", "asv", "gmpy2", "hypothesis (>=6.30)", "meson", "mpmath", "ninja", "pooch", "pytest", "pytest-cov", "pytest-timeout", "pytest-xdist", "scikit-umfpack", "threadpoolctl"] +test = ["Cython", "array-api-strict (>=2.0)", "asv", "gmpy2", "hypothesis (>=6.30)", "meson", "mpmath", "ninja ; sys_platform != \"emscripten\"", "pooch", "pytest", "pytest-cov", "pytest-timeout", "pytest-xdist", "scikit-umfpack", "threadpoolctl"] [[package]] name = "tomli" @@ -564,6 +590,8 @@ version = "2.2.1" description = "A lil' TOML parser" optional = false python-versions = ">=3.8" +groups = ["dev"] +markers = "python_version < \"3.11\"" files = [ {file = "tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249"}, {file = "tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6"}, @@ -605,6 +633,7 @@ version = "20.28.0" description = "Virtual Python Environment builder" optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "virtualenv-20.28.0-py3-none-any.whl", hash = "sha256:23eae1b4516ecd610481eda647f3a7c09aea295055337331bb4e6892ecce47b0"}, {file = "virtualenv-20.28.0.tar.gz", hash = "sha256:2c9c3262bb8e7b87ea801d715fae4495e6032450c71d2309be9550e7364049aa"}, @@ -617,9 +646,9 @@ platformdirs = ">=3.9.1,<5" [package.extras] docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.2,!=7.3)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"] -test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.4)", "pytest-env (>=0.8.2)", "pytest-freezer (>=0.4.8)", "pytest-mock (>=3.11.1)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=68)", "time-machine (>=2.10)"] +test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.4)", "pytest-env (>=0.8.2)", "pytest-freezer (>=0.4.8) ; platform_python_implementation == \"PyPy\" or platform_python_implementation == \"CPython\" and sys_platform == \"win32\" and python_version >= \"3.13\"", "pytest-mock (>=3.11.1)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=68)", "time-machine (>=2.10) ; platform_python_implementation == \"CPython\""] [metadata] -lock-version = "2.0" +lock-version = "2.1" python-versions = "^3.9" -content-hash = "c4129df26d98ea12885d07b14380f5ad735e06654941c80e999108ad4a6e5ce1" +content-hash = "e31837e2ff1c9a1dfea8309466c022d76a6336a620c356bcfbe6bd84158499b2" diff --git a/py360convert/utils.py b/py360convert/utils.py index 7a8d9ff..361ffeb 100644 --- a/py360convert/utils.py +++ b/py360convert/utils.py @@ -5,8 +5,6 @@ import numpy as np from numpy.typing import NDArray -from scipy.ndimage import map_coordinates -from scipy.spatial.transform import Rotation try: import cv2 # pyright: ignore[reportMissingImports] @@ -340,6 +338,146 @@ def coor2uv(coorxy: NDArray[DType], h: int, w: int) -> NDArray[DType]: return np.concatenate([u, v], axis=-1, dtype=coorxy.dtype) +def _map_coordinates_nearest(img, coords): + # coords: (2, H, W) + y, x = coords + y = np.round(y).astype(int) + x = np.round(x).astype(int) + y = np.clip(y, 0, img.shape[0] - 1) + x = np.clip(x, 0, img.shape[1] - 1) + return img[y, x] + + +def _map_coordinates_linear(img, coords): + # Bilinear interpolation for 2D images + y, x = coords + x0 = np.floor(x).astype(int) + x1 = x0 + 1 + y0 = np.floor(y).astype(int) + y1 = y0 + 1 + x0 = np.clip(x0, 0, img.shape[1] - 1) + x1 = np.clip(x1, 0, img.shape[1] - 1) + y0 = np.clip(y0, 0, img.shape[0] - 1) + y1 = np.clip(y1, 0, img.shape[0] - 1) + Ia = img[y0, x0] + Ib = img[y1, x0] + Ic = img[y0, x1] + Id = img[y1, x1] + wa = (x1 - x) * (y1 - y) + wb = (x1 - x) * (y - y0) + wc = (x - x0) * (y1 - y) + wd = (x - x0) * (y - y0) + return wa * Ia + wb * Ib + wc * Ic + wd * Id + + +def _cubic_kernel(x): + """Cubic convolution kernel (Catmull-Rom spline, a= -0.5).""" + absx = np.abs(x) + absx2 = absx**2 + absx3 = absx**3 + a = -0.5 + k = ((a + 2) * absx3 - (a + 3) * absx2 + 1) * (absx <= 1) + (a * absx3 - 5 * a * absx2 + 8 * a * absx - 4 * a) * ( + (absx > 1) & (absx < 2) + ) + return k + + +def _map_coordinates_cubic(img, coords): + # Bicubic interpolation for 2D images + y, x = coords + h, w = img.shape[:2] + x0 = np.floor(x).astype(int) + y0 = np.floor(y).astype(int) + # 4x4 neighborhood + result = np.zeros_like(x, dtype=img.dtype) + for m in range(-1, 3): + for n in range(-1, 3): + xm = np.clip(x0 + n, 0, w - 1) + ym = np.clip(y0 + m, 0, h - 1) + wx = _cubic_kernel(x - (x0 + n)) + wy = _cubic_kernel(y - (y0 + m)) + wxy = wx * wy + result += img[ym, xm] * wxy + return result + + +def _cube_faces_nearest_interp(img, coords): + tp, y, x = coords + t = tp.astype(int) + y = np.round(y).astype(int) + x = np.round(x).astype(int) + t = np.clip(t, 0, img.shape[0] - 1) + y = np.clip(y, 0, img.shape[1] - 1) + x = np.clip(x, 0, img.shape[2] - 1) + return img[t, y, x] + + +def _cube_faces_linear_interp(img, coord): + tp, y, x = coord + t = tp.astype(int) + t = np.clip(t, 0, img.shape[0] - 1) + y0 = np.floor(y).astype(int) + y1 = y0 + 1 + x0 = np.floor(x).astype(int) + x1 = x0 + 1 + y0 = np.clip(y0, 0, img.shape[1] - 1) + y1 = np.clip(y1, 0, img.shape[1] - 1) + x0 = np.clip(x0, 0, img.shape[2] - 1) + x1 = np.clip(x1, 0, img.shape[2] - 1) + Ia = img[t, y0, x0] + Ib = img[t, y1, x0] + Ic = img[t, y0, x1] + Id = img[t, y1, x1] + wa = (x1 - x) * (y1 - y) + wb = (x1 - x) * (y - y0) + wc = (x - x0) * (y1 - y) + wd = (x - x0) * (y - y0) + return wa * Ia + wb * Ib + wc * Ic + wd * Id + + +def _cube_faces_cubic_interp(img, coords): + # Bicubic interpolation for 3D cube faces + tp, y, x = coords + t = tp.astype(int) + t = np.clip(t, 0, img.shape[0] - 1) + y0 = np.floor(y).astype(int) + x0 = np.floor(x).astype(int) + out = np.zeros_like(y, dtype=img.dtype) + for m in range(-1, 3): + ym = np.clip(y0 + m, 0, img.shape[1] - 1) + wy = _cubic_kernel(y - (y0 + m)) + for n in range(-1, 3): + xm = np.clip(x0 + n, 0, img.shape[2] - 1) + wx = _cubic_kernel(x - (x0 + n)) + wxy = wy * wx + out += img[t, ym, xm] * wxy + return out + + +def map_coordinates(input: NDArray, coordinates: Union[NDArray, tuple[NDArray, ...]], order: int = 1) -> NDArray: + if order not in (0, 1, 3): + raise NotImplementedError("Only nearest, linear, and cubic interpolation are supported.") + + out: NDArray = np.array([]) # Initialize out variable + + if len(coordinates) == 2: + if order == 0: + out = _map_coordinates_nearest(input, coordinates) + elif order == 1: + out = _map_coordinates_linear(input, coordinates) + elif order == 3: + out = _map_coordinates_cubic(input, coordinates) + elif len(coordinates) == 3: + if order == 0: + out = _cube_faces_nearest_interp(input, coordinates) + elif order == 1: + out = _cube_faces_linear_interp(input, coordinates) + elif order == 3: + out = _cube_faces_cubic_interp(input, coordinates) + + return out + + class EquirecSampler: def __init__( self, @@ -377,12 +515,8 @@ def __init__( self._order = order def __call__(self, img: NDArray[DType]) -> NDArray[DType]: - if img.dtype == np.float16: - source_dtype = np.float16 - else: - source_dtype = None - - if source_dtype: + source_dtype = img.dtype + if source_dtype == np.float16: img = img.astype(np.float32) # pyright: ignore padded = self._pad(img) @@ -397,7 +531,7 @@ def __call__(self, img: NDArray[DType]) -> NDArray[DType]: order=self._order, )[..., 0] - if source_dtype: + if source_dtype == np.float16: out = out.astype(source_dtype) return out # pyright: ignore[reportReturnType] @@ -552,12 +686,8 @@ def __call__(self, cube_faces: NDArray[DType]) -> NDArray[DType]: if w != self._w: raise ValueError(f"Input width {w} doesn't match expected height {self._w}.") - if cube_faces.dtype == np.float16: - source_dtype = np.float16 - else: - source_dtype = None - - if source_dtype: + source_dtype = cube_faces.dtype + if source_dtype == np.float16: cube_faces = cube_faces.astype(np.float32) # pyright: ignore padded = self._pad(cube_faces) @@ -571,7 +701,7 @@ def __call__(self, cube_faces: NDArray[DType]) -> NDArray[DType]: # map_coordinates can handle uint8, float32, float64 out = map_coordinates(padded, (self._tp, self._coor_y, self._coor_x), order=self._order) - if source_dtype: + if source_dtype == np.float16: out = out.astype(source_dtype) return out # pyright: ignore[reportReturnType] @@ -769,11 +899,37 @@ def cube_dice2h(cube_dice: NDArray[DType]) -> NDArray[DType]: return cube_h +def rotation_matrix_from_rodrigues(rad: float, axis: Union[int, NDArray, Sequence[float]]) -> NDArray: + # Rodrigues' rotation formula https://en.wikipedia.org/wiki/Rodrigues%27_rotation_formula + axis_array = np.array(axis, dtype=float) + normalized_axis = axis_array / np.linalg.norm(axis_array) + cos_half = np.cos(rad / 2.0) + axis_x, axis_y, axis_z = -normalized_axis * np.sin(rad / 2.0) + cos2 = cos_half * cos_half + x2 = axis_x * axis_x + y2 = axis_y * axis_y + z2 = axis_z * axis_z + xy = axis_x * axis_y + xz = axis_x * axis_z + yz = axis_y * axis_z + cos_z = cos_half * axis_z + cos_y = cos_half * axis_y + cos_x = cos_half * axis_x + R = np.array( + [ + [cos2 + x2 - y2 - z2, 2 * (xy + cos_z), 2 * (xz - cos_y)], + [2 * (xy - cos_z), cos2 + y2 - x2 - z2, 2 * (yz + cos_x)], + [2 * (xz + cos_y), 2 * (yz - cos_x), cos2 + z2 - x2 - y2], + ] + ) + return R + + def rotation_matrix(rad: float, ax: Union[int, NDArray, Sequence]): if isinstance(ax, int): ax = (np.arange(3) == ax).astype(float) - ax = np.array(ax) + ax = np.array(ax, dtype=float) if ax.shape != (3,): raise ValueError(f"ax must be shape (3,); got {ax.shape}") - R = Rotation.from_rotvec(rad * ax).as_matrix() + R = rotation_matrix_from_rodrigues(rad, ax) return R diff --git a/pyproject.toml b/pyproject.toml index 7af62b8..5f4d25c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,16 +31,13 @@ numpy = [ {version = ">=1.20.0", python = "<3.13"}, {version = ">=2.1.0", python = ">=3.13"}, ] -scipy = [ - {version = ">=1.2.0", python = "<3.13"}, - {version = ">=1.14.0", python = ">=3.13"}, -] pillow = ">=6.0.0" [tool.poetry.group.dev.dependencies] pre_commit = ">=2.16.0" pytest = ">=7.1.2" pytest-mock = ">=3.7.0" +scipy = ">=1.7.0" [tool.pyright] venvPath = "." diff --git a/tests/test_utils.py b/tests/test_utils.py index 88d74a7..54418d4 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,6 +1,9 @@ import numpy as np +import pytest +from scipy.ndimage import map_coordinates as scipy_map_coordinates import py360convert +from py360convert.utils import map_coordinates def test_dice2h_h2dice(dice_image): @@ -12,3 +15,40 @@ def test_dice2h_h2dice(dice_image): # Round trip should result in the same image. np.testing.assert_allclose(dice_image, dice_actual) + + +@pytest.mark.parametrize("order", [0, 1, 3]) +def test_map_coordinates_vs_scipy(order): + if order == 0: + img = np.arange(16).reshape(4, 4) + coords = np.array([[1.2, 2.7], [0.4, 3.6]]) # y, x + result = map_coordinates(img, coords, order=order) + expected = scipy_map_coordinates(img, coords, order=order, mode="nearest") + np.testing.assert_allclose(result, expected) + else: + img = np.arange(16).reshape(4, 4).astype(float) + coords = np.array([[1.5, 2.2], [0.5, 3.1]]) # y, x + result = map_coordinates(img, coords, order=order) + expected = scipy_map_coordinates(img, coords, order=order, mode="nearest") + rtol = 1e-5 if order == 1 else 1e-4 + atol = 1e-5 if order == 1 else 1e-4 + np.testing.assert_allclose(result, expected, rtol=rtol, atol=atol) + + +@pytest.mark.parametrize("order", [0, 1, 3]) +def test_map_coordinates_cube_faces_vs_scipy(order): + # 6 faces, 4x4 pixels + img = np.arange(6 * 4 * 4).reshape(6, 4, 4).astype(float) + tp = np.array([0, 1, 2, 3, 4, 5]) + y = np.array([1.2, 2.7, 0.5, 3.1, 2.2, 1.5]) + x = np.array([0.4, 3.6, 2.1, 1.9, 0.0, 3.0]) + coords = (tp, y, x) + result = map_coordinates(img, coords, order=order) + + # 각 face별로 2D 슬라이스와 좌표를 꺼내서 scipy와 비교 + for i in range(len(tp)): + face = int(tp[i]) + y_i = np.array([y[i]]) + x_i = np.array([x[i]]) + expected = scipy_map_coordinates(img[face], np.vstack([y_i, x_i]), order=order, mode="nearest") + np.testing.assert_allclose(result[i], expected, rtol=1e-4, atol=1e-4)