From ad6a0a35fc8751a23eb707440f74e29ccec81204 Mon Sep 17 00:00:00 2001 From: Pierre Paleo Date: Wed, 8 Jul 2026 15:46:24 +0200 Subject: [PATCH 1/7] Fix pyproject.toml to ship font file --- pyproject.toml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 7e20c4d6..b78ccfeb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,6 +31,15 @@ memory = [ "psutil" ] +[tool.setuptools.package-data] +"vvv" = ["fonts/*.otf"] + +[tool.setuptools] +package-dir = {"" = "src"} +include-package-data = true + +[tool.setuptools.packages.find] +where = ["src"] [project.scripts] # This line creates the 'vvv' command in your shell @@ -43,3 +52,4 @@ testpaths = ["tests", "src/vvv/plugins"] filterwarnings = [ "ignore::DeprecationWarning", ] + From 8fc4d73abce7975cc03009088787010f224f8bcb Mon Sep 17 00:00:00 2001 From: Thomas BAUDIER Date: Fri, 19 Jun 2026 07:57:55 +0200 Subject: [PATCH 2/7] Start to create the CI on github actions --- .github/workflows/create_wheel.yml | 115 +++++++++++++++++++++++++++++ 1 file changed, 115 insertions(+) create mode 100644 .github/workflows/create_wheel.yml diff --git a/.github/workflows/create_wheel.yml b/.github/workflows/create_wheel.yml new file mode 100644 index 00000000..3fff69a8 --- /dev/null +++ b/.github/workflows/create_wheel.yml @@ -0,0 +1,115 @@ + +name: Publish package + +on: + push: + branches: [ master, wheel ] + tags: + - '*' + pull_request: + branches: + - '*' + + +jobs: + build-wheel: + runs-on: ubuntu-latest + + steps: + - name: Clone github repository + uses: actions/checkout@v6 + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: "3.14" + + - run: pip install build + - run: python -m build + + - name: Upload wheels to GH artifacts + uses: actions/upload-artifact@v7 + with: + name: wheel + path: dist/ + + upload-wheel: + runs-on: ubuntu-latest + needs: [build-wheel] + + steps: + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: "3.14" + + - uses: actions/download-artifact@v8 + with: + pattern: wheel* + path: dist/ + + #- name: Publish to test PyPI + # if: github.event_name == 'push' && startsWith(github.event.ref, 'refs/tags/') + # uses: pypa/gh-action-pypi-publish@release/v1 + # with: + # user: __token__ + # password: ${{ secrets.TEST_PYPI_API_TOKEN }} + # packages-dir: dist/ + # skip-existing: true + # repository-url: https://test.pypi.org/legacy/ + + test-wheel: + needs: [build-wheel] + + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] + + runs-on: ${{ matrix.os }} + + steps: + - uses: actions/download-artifact@v8 + with: + pattern: wheel* + path: dist/ + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: ${{ matrix.python-version }} + + - name: Install package (unix) + if: runner.os != 'Windows' + run: | + ls -l dist + python -m pip install --upgrade pip + pip install dist/*.whl + + + - name: Install package (Windows) + if: runner.os == 'Windows' + run: | + python -m pip install --upgrade pip + Get-ChildItem dist\*.whl | ForEach-Object { pip install $_.FullName } + + - name: Install test dependencies + run: pip install pytest + + - name: Clone github repository + uses: actions/checkout@v6 + + + - name: Remove src (Unix) + if: runner.os != 'Windows' + run: rm -rf src + + - name: Remove src (Windows) + if: runner.os == 'Windows' + run: Remove-Item -Recurse -Force src + + - name: Run tests + run: | + pytest -v + + From 10a234039e253bedbbba685abb5d4d2b7d48cf25 Mon Sep 17 00:00:00 2001 From: Thomas BAUDIER Date: Fri, 19 Jun 2026 08:49:51 +0200 Subject: [PATCH 3/7] Change character to a universal char. On windows the previous equal-like character leads to an error because it's not utf-8 encoding --- tests/bench_fusion_modes.py | 8 ++++---- tests/bench_fusion_roi_large.py | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/bench_fusion_modes.py b/tests/bench_fusion_modes.py index 8e39aff2..a50dc3f8 100755 --- a/tests/bench_fusion_modes.py +++ b/tests/bench_fusion_modes.py @@ -228,9 +228,9 @@ def main(): os_label = platform.system() sep = "-" * 102 - print(f"\n{'═'*102}") + print(f"\n{'='*102}") print(f" {os_label} · Full NN every frame (lazy_nn=False) · {n_iters} iters/action") - print(f"{'═'*102}") + print(f"{'='*102}") print(f"{'Mode':<28} | {'Slicing':>9} | {'Pan Move':>9} | {'Zoom In':>9} | {'Zoom Out':>9} | {'W/L (W)':>9} | {'W/L (X)':>9}") print(sep) @@ -250,9 +250,9 @@ def main(): note = (" Pan(live) = bilinear during drag | " "Settle = one NN upload after 150ms pause (= Pan Move cost above)") - print(f"\n{'═'*78}") + print(f"\n{'='*78}") print(f" {os_label} · Lazy NN (lazy_nn=True, settle={int(LAZY_SETTLE_S*1000)}ms) · {n_iters} iters/action") - print(f"{'═'*78}") + print(f"{'='*78}") print(hdr_lazy) print(sep) diff --git a/tests/bench_fusion_roi_large.py b/tests/bench_fusion_roi_large.py index 0140c2e2..cb97941f 100755 --- a/tests/bench_fusion_roi_large.py +++ b/tests/bench_fusion_roi_large.py @@ -239,10 +239,10 @@ def main(): os_label = platform.system() sep = "-" * 96 - print(f"\n{'═'*96}") + print(f"\n{'='*96}") print(f" {os_label} · Fusion + Large ROI · {n_iters} iters/action") print(f" ROI coverage: 50% box, {'ON' if roi_ready else 'FAILED TO LOAD'}") - print(f"{'═'*96}") + print(f"{'='*96}") print(f"{'Mode':<24} | {'Slicing':>9} | {'Pan Move':>9} | {'Zoom In':>9} | {'Zoom Out':>9} | {'W/L (W)':>9} | {'W/L (X)':>9}") print(sep) From 67d641d91dad3d729e6435a4613b30485104be6a Mon Sep 17 00:00:00 2001 From: Thomas BAUDIER Date: Fri, 19 Jun 2026 08:56:36 +0200 Subject: [PATCH 4/7] relpath for Windows is not usable --- tests/test_settings.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/test_settings.py b/tests/test_settings.py index 86c2743f..caac6d7d 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -1,3 +1,4 @@ +import platform import unittest from typing import cast from vvv.core.controller import Controller @@ -49,6 +50,7 @@ def test_add_recent_file_absolute(self): expected_list = json.dumps([os.path.abspath("d1.dcm"), os.path.abspath("d2.dcm")]) self.assertEqual(recent[0], expected_list) + @unittest.skipIf(platform.system() == "Windows", "Unix-specific test") def test_resolve_recent_path_home_users(self): # Using a file that is known to exist inside the repository import os From fa226a0e3d55e84ab8161194c1f3943fe6850e80 Mon Sep 17 00:00:00 2001 From: Thomas BAUDIER Date: Tue, 25 Aug 2026 15:59:55 +0200 Subject: [PATCH 5/7] Uncomment pypi because we are ready --- .github/workflows/create_wheel.yml | 17 ++++++++--------- tests/bench_fusion_roi_large.py | 2 +- 2 files changed, 9 insertions(+), 10 deletions(-) diff --git a/.github/workflows/create_wheel.yml b/.github/workflows/create_wheel.yml index 3fff69a8..f678d137 100644 --- a/.github/workflows/create_wheel.yml +++ b/.github/workflows/create_wheel.yml @@ -47,15 +47,14 @@ jobs: pattern: wheel* path: dist/ - #- name: Publish to test PyPI - # if: github.event_name == 'push' && startsWith(github.event.ref, 'refs/tags/') - # uses: pypa/gh-action-pypi-publish@release/v1 - # with: - # user: __token__ - # password: ${{ secrets.TEST_PYPI_API_TOKEN }} - # packages-dir: dist/ - # skip-existing: true - # repository-url: https://test.pypi.org/legacy/ + - name: Publish to PyPI + if: github.event_name == 'push' && startsWith(github.event.ref, 'refs/tags/') + uses: pypa/gh-action-pypi-publish@release/v1 + with: + user: __token__ + password: ${{ secrets.TEST_PYPI_API_TOKEN }} + packages-dir: dist/ + skip-existing: true test-wheel: needs: [build-wheel] diff --git a/tests/bench_fusion_roi_large.py b/tests/bench_fusion_roi_large.py index cb97941f..ec6c490d 100755 --- a/tests/bench_fusion_roi_large.py +++ b/tests/bench_fusion_roi_large.py @@ -256,7 +256,7 @@ def main(): (1.0 / r - 1.0 / n) * 1000 if r > 0 and n > 0 else 0.0 for n, r in zip(row_no_roi, row_roi) ] - print(fmt_row(" Δ ROI cost", delta, unit="ms")) + print(fmt_row(" delta ROI cost", delta, unit="ms")) print(sep) print( From 7a22abc1ccca71733d5ff68a2f105e88184ca492 Mon Sep 17 00:00:00 2001 From: Thomas BAUDIER Date: Tue, 25 Aug 2026 16:21:42 +0200 Subject: [PATCH 6/7] Set version 0.5.0 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 3d7f274e..94cab1ec 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "vvv" -version = "1.0" +version = "0.5.0" description = "A Python-based 3D/4D image viewer inspired by VV." requires-python = ">=3.10" dependencies = [ From 5d37567e82acc036e97ad91a76c51d9946ce9ddb Mon Sep 17 00:00:00 2001 From: Thomas BAUDIER Date: Wed, 26 Aug 2026 16:27:50 +0200 Subject: [PATCH 7/7] Do not test mip on Github mac Numba return an error due to openmp backend --- .github/workflows/create_wheel.yml | 6 +- src/vvv/plugins/mip/math_mip.py | 241 +++++++++++++++++++---------- tests/test_gui.py | 3 + tests/test_mip_integration.py | 9 +- tests/test_settings.py | 3 +- 5 files changed, 174 insertions(+), 88 deletions(-) diff --git a/.github/workflows/create_wheel.yml b/.github/workflows/create_wheel.yml index f678d137..6d8864a1 100644 --- a/.github/workflows/create_wheel.yml +++ b/.github/workflows/create_wheel.yml @@ -3,7 +3,7 @@ name: Publish package on: push: - branches: [ master, wheel ] + branches: [ master ] tags: - '*' pull_request: @@ -109,6 +109,6 @@ jobs: - name: Run tests run: | + ls + pip freeze pytest -v - - diff --git a/src/vvv/plugins/mip/math_mip.py b/src/vvv/plugins/mip/math_mip.py index 38322941..8a964e27 100644 --- a/src/vvv/plugins/mip/math_mip.py +++ b/src/vvv/plugins/mip/math_mip.py @@ -1,23 +1,28 @@ -import numpy as np - from typing import Any +import numpy as np + numba: Any try: import numba as _numba + numba = _numba _NUMBA_AVAILABLE = True except ImportError: + class _Dummy: def njit(self, *args, **kwargs): return lambda f: f + def prange(self, *args): return range(*args) + numba = _Dummy() _NUMBA_AVAILABLE = False if _NUMBA_AVAILABLE: + @numba.njit(parallel=True, cache=True, fastmath=True) def project_mip_z(data: np.ndarray, depth_cueing_strength: float) -> np.ndarray: """Compute MIP along the Z (depth) axis using Numba. Output shape is (H, W).""" @@ -36,7 +41,6 @@ def project_mip_z(data: np.ndarray, depth_cueing_strength: float) -> np.ndarray: out[y, x] = max_val return out - @numba.njit(parallel=True, cache=True, fastmath=True) def project_mip_y(data: np.ndarray, depth_cueing_strength: float) -> np.ndarray: """Compute MIP along the Y (depth) axis using Numba. Output shape is (D, W).""" @@ -55,7 +59,6 @@ def project_mip_y(data: np.ndarray, depth_cueing_strength: float) -> np.ndarray: out[z, x] = max_val return out - @numba.njit(parallel=True, cache=True, fastmath=True) def project_mip_x(data: np.ndarray, depth_cueing_strength: float) -> np.ndarray: """Compute MIP along the X (depth) axis using Numba. Output shape is (D, H).""" @@ -74,25 +77,26 @@ def project_mip_x(data: np.ndarray, depth_cueing_strength: float) -> np.ndarray: out[z, y] = max_val return out - @numba.njit(parallel=True, cache=True, fastmath=True) - def project_mip_z_rotated(data: np.ndarray, theta: float, depth_cueing_strength: float) -> np.ndarray: + def project_mip_z_rotated( + data: np.ndarray, theta: float, depth_cueing_strength: float + ) -> np.ndarray: """Compute rotated MIP along Z (rotation around Y). Output shape is (H, W).""" D, H, W = data.shape diag = int(np.ceil(np.sqrt(D**2 + W**2))) out = np.zeros((H, W), dtype=data.dtype) - + cz = (D - 1) / 2.0 cx = (W - 1) / 2.0 cos_t = np.cos(theta) sin_t = np.sin(theta) - + # Precompute coordinates and bounds r_start = np.zeros(W, dtype=np.int32) r_end = np.zeros(W, dtype=np.int32) x_indices = np.zeros((W, diag), dtype=np.int32) z_indices = np.zeros((W, diag), dtype=np.int32) - + for x_out in range(W): v = x_out - cx first_valid = -1 @@ -111,7 +115,7 @@ def project_mip_z_rotated(data: np.ndarray, theta: float, depth_cueing_strength: z_indices[x_out, r] = zi r_start[x_out] = first_valid r_end[x_out] = last_valid + 1 if last_valid != -1 else -1 - + for y in numba.prange(H): # type: ignore for x_out in range(W): rs = r_start[x_out] @@ -122,38 +126,41 @@ def project_mip_z_rotated(data: np.ndarray, theta: float, depth_cueing_strength: factor = 1.0 - depth_cueing_strength * (rs / max(1.0, diag - 1)) val0 = val0 * factor max_val = val0 - + for r in range(rs + 1, re): xi = x_indices[x_out, r] zi = z_indices[x_out, r] val = data[zi, y, xi] if depth_cueing_strength > 0.0: - factor = 1.0 - depth_cueing_strength * (r / max(1.0, diag - 1)) + factor = 1.0 - depth_cueing_strength * ( + r / max(1.0, diag - 1) + ) val = val * factor if val > max_val: max_val = val out[y, x_out] = max_val return out - @numba.njit(parallel=True, cache=True, fastmath=True) - def project_mip_y_rotated(data: np.ndarray, theta: float, depth_cueing_strength: float) -> np.ndarray: + def project_mip_y_rotated( + data: np.ndarray, theta: float, depth_cueing_strength: float + ) -> np.ndarray: """Compute rotated MIP along Y (rotation around Z). Output shape is (D, W).""" D, H, W = data.shape diag = int(np.ceil(np.sqrt(H**2 + W**2))) out = np.zeros((D, W), dtype=data.dtype) - + cy = (H - 1) / 2.0 cx = (W - 1) / 2.0 cos_t = np.cos(theta) sin_t = np.sin(theta) - + # Precompute coordinates and bounds r_start = np.zeros(W, dtype=np.int32) r_end = np.zeros(W, dtype=np.int32) x_indices = np.zeros((W, diag), dtype=np.int32) y_indices = np.zeros((W, diag), dtype=np.int32) - + for x_out in range(W): v = x_out - cx first_valid = -1 @@ -172,7 +179,7 @@ def project_mip_y_rotated(data: np.ndarray, theta: float, depth_cueing_strength: y_indices[x_out, r] = yi r_start[x_out] = first_valid r_end[x_out] = last_valid + 1 if last_valid != -1 else -1 - + for z in numba.prange(D): # type: ignore for x_out in range(W): rs = r_start[x_out] @@ -183,38 +190,41 @@ def project_mip_y_rotated(data: np.ndarray, theta: float, depth_cueing_strength: factor = 1.0 - depth_cueing_strength * (rs / max(1.0, diag - 1)) val0 = val0 * factor max_val = val0 - + for r in range(rs + 1, re): xi = x_indices[x_out, r] yi = y_indices[x_out, r] val = data[z, yi, xi] if depth_cueing_strength > 0.0: - factor = 1.0 - depth_cueing_strength * (r / max(1.0, diag - 1)) + factor = 1.0 - depth_cueing_strength * ( + r / max(1.0, diag - 1) + ) val = val * factor if val > max_val: max_val = val out[z, x_out] = max_val return out - @numba.njit(parallel=True, cache=True, fastmath=True) - def project_mip_x_rotated(data: np.ndarray, theta: float, depth_cueing_strength: float) -> np.ndarray: + def project_mip_x_rotated( + data: np.ndarray, theta: float, depth_cueing_strength: float + ) -> np.ndarray: """Compute rotated MIP along X (rotation around Z). Output shape is (D, H).""" D, H, W = data.shape diag = int(np.ceil(np.sqrt(W**2 + H**2))) out = np.zeros((D, H), dtype=data.dtype) - + cx = (W - 1) / 2.0 cy = (H - 1) / 2.0 cos_t = np.cos(theta) sin_t = np.sin(theta) - + # Precompute coordinates and bounds r_start = np.zeros(H, dtype=np.int32) r_end = np.zeros(H, dtype=np.int32) y_indices = np.zeros((H, diag), dtype=np.int32) x_indices = np.zeros((H, diag), dtype=np.int32) - + for y_out in range(H): v = y_out - cy first_valid = -1 @@ -233,7 +243,7 @@ def project_mip_x_rotated(data: np.ndarray, theta: float, depth_cueing_strength: x_indices[y_out, r] = xi r_start[y_out] = first_valid r_end[y_out] = last_valid + 1 if last_valid != -1 else -1 - + for z in numba.prange(D): # type: ignore for y_out in range(H): rs = r_start[y_out] @@ -244,157 +254,210 @@ def project_mip_x_rotated(data: np.ndarray, theta: float, depth_cueing_strength: factor = 1.0 - depth_cueing_strength * (rs / max(1.0, diag - 1)) val0 = val0 * factor max_val = val0 - + for r in range(rs + 1, re): yi = y_indices[y_out, r] xi = x_indices[y_out, r] val = data[z, yi, xi] if depth_cueing_strength > 0.0: - factor = 1.0 - depth_cueing_strength * (r / max(1.0, diag - 1)) + factor = 1.0 - depth_cueing_strength * ( + r / max(1.0, diag - 1) + ) val = val * factor if val > max_val: max_val = val out[z, y_out] = max_val return out + else: + def project_mip_z(data: np.ndarray, depth_cueing_strength: float) -> np.ndarray: """Compute MIP along the Z (depth) axis using NumPy. Output shape is (H, W).""" D, H, W = data.shape if depth_cueing_strength <= 0.0: return np.max(data, axis=0) - factors = 1.0 - depth_cueing_strength * (np.arange(D, dtype=np.float32) / max(1.0, D - 1)) + factors = 1.0 - depth_cueing_strength * ( + np.arange(D, dtype=np.float32) / max(1.0, D - 1) + ) # Add dimensions for broadcasting: shape (D, 1, 1) factors = factors[:, np.newaxis, np.newaxis] attenuated = data * factors return np.max(attenuated, axis=0) - def project_mip_y(data: np.ndarray, depth_cueing_strength: float) -> np.ndarray: """Compute MIP along the Y (depth) axis using NumPy. Output shape is (D, W).""" D, H, W = data.shape if depth_cueing_strength <= 0.0: return np.max(data, axis=1) - factors = 1.0 - depth_cueing_strength * (np.arange(H, dtype=np.float32) / max(1.0, H - 1)) + factors = 1.0 - depth_cueing_strength * ( + np.arange(H, dtype=np.float32) / max(1.0, H - 1) + ) # Add dimensions for broadcasting: shape (1, H, 1) factors = factors[np.newaxis, :, np.newaxis] attenuated = data * factors return np.max(attenuated, axis=1) - def project_mip_x(data: np.ndarray, depth_cueing_strength: float) -> np.ndarray: """Compute MIP along the X (depth) axis using NumPy. Output shape is (D, H).""" D, H, W = data.shape if depth_cueing_strength <= 0.0: return np.max(data, axis=2) - factors = 1.0 - depth_cueing_strength * (np.arange(W, dtype=np.float32) / max(1.0, W - 1)) + factors = 1.0 - depth_cueing_strength * ( + np.arange(W, dtype=np.float32) / max(1.0, W - 1) + ) # Add dimensions for broadcasting: shape (1, 1, W) factors = factors[np.newaxis, np.newaxis, :] attenuated = data * factors return np.max(attenuated, axis=2) - - def project_mip_z_rotated(data: np.ndarray, theta: float, depth_cueing_strength: float) -> np.ndarray: + def project_mip_z_rotated( + data: np.ndarray, theta: float, depth_cueing_strength: float + ) -> np.ndarray: """Compute rotated MIP along Z using NumPy. Output shape is (H, W).""" D, H, W = data.shape diag = int(np.ceil(np.sqrt(D**2 + W**2))) - + cz = (D - 1) / 2.0 cx = (W - 1) / 2.0 cos_t = np.cos(theta) sin_t = np.sin(theta) - + u = np.arange(diag, dtype=np.float32) - (diag - 1) / 2.0 - + x_out = np.arange(W)[:, np.newaxis] v = x_out - cx - + xr = np.round(cx + v * cos_t - u * sin_t).astype(np.int32) zr = np.round(cz + v * sin_t + u * cos_t).astype(np.int32) - + mask = (xr >= 0) & (xr < W) & (zr >= 0) & (zr < D) - + xr_clipped = np.clip(xr, 0, W - 1) zr_clipped = np.clip(zr, 0, D - 1) - - vals = data[zr_clipped[:, np.newaxis, :], np.arange(H)[np.newaxis, :, np.newaxis], xr_clipped[:, np.newaxis, :]] - + + vals = data[ + zr_clipped[:, np.newaxis, :], + np.arange(H)[np.newaxis, :, np.newaxis], + xr_clipped[:, np.newaxis, :], + ] + if depth_cueing_strength > 0.0: - factors = 1.0 - depth_cueing_strength * (np.arange(diag, dtype=np.float32) / max(1.0, diag - 1))[np.newaxis, np.newaxis, :] + factors = ( + 1.0 + - depth_cueing_strength + * (np.arange(diag, dtype=np.float32) / max(1.0, diag - 1))[ + np.newaxis, np.newaxis, : + ] + ) vals = vals * factors - - fill_value = np.iinfo(data.dtype).min if np.issubdtype(data.dtype, np.integer) else -np.inf + + fill_value = ( + np.iinfo(data.dtype).min + if np.issubdtype(data.dtype, np.integer) + else -np.inf + ) vals = np.where(mask[:, np.newaxis, :], vals, fill_value) - - return np.max(vals, axis=2).T.astype(data.dtype) + return np.max(vals, axis=2).T.astype(data.dtype) - def project_mip_y_rotated(data: np.ndarray, theta: float, depth_cueing_strength: float) -> np.ndarray: + def project_mip_y_rotated( + data: np.ndarray, theta: float, depth_cueing_strength: float + ) -> np.ndarray: """Compute rotated MIP along Y using NumPy. Output shape is (D, W).""" D, H, W = data.shape diag = int(np.ceil(np.sqrt(H**2 + W**2))) - + cy = (H - 1) / 2.0 cx = (W - 1) / 2.0 cos_t = np.cos(theta) sin_t = np.sin(theta) - + u = np.arange(diag, dtype=np.float32) - (diag - 1) / 2.0 - + x_out = np.arange(W)[:, np.newaxis] v = x_out - cx - + xr = np.round(cx + v * cos_t - u * sin_t).astype(np.int32) yr = np.round(cy + v * sin_t + u * cos_t).astype(np.int32) - + mask = (xr >= 0) & (xr < W) & (yr >= 0) & (yr < H) - + xr_clipped = np.clip(xr, 0, W - 1) yr_clipped = np.clip(yr, 0, H - 1) - - vals = data[np.arange(D)[np.newaxis, :, np.newaxis], yr_clipped[:, np.newaxis, :], xr_clipped[:, np.newaxis, :]] - + + vals = data[ + np.arange(D)[np.newaxis, :, np.newaxis], + yr_clipped[:, np.newaxis, :], + xr_clipped[:, np.newaxis, :], + ] + if depth_cueing_strength > 0.0: - factors = 1.0 - depth_cueing_strength * (np.arange(diag, dtype=np.float32) / max(1.0, diag - 1))[np.newaxis, np.newaxis, :] + factors = ( + 1.0 + - depth_cueing_strength + * (np.arange(diag, dtype=np.float32) / max(1.0, diag - 1))[ + np.newaxis, np.newaxis, : + ] + ) vals = vals * factors - - fill_value = np.iinfo(data.dtype).min if np.issubdtype(data.dtype, np.integer) else -np.inf + + fill_value = ( + np.iinfo(data.dtype).min + if np.issubdtype(data.dtype, np.integer) + else -np.inf + ) vals = np.where(mask[:, np.newaxis, :], vals, fill_value) - - return np.max(vals, axis=2).T.astype(data.dtype) + return np.max(vals, axis=2).T.astype(data.dtype) - def project_mip_x_rotated(data: np.ndarray, theta: float, depth_cueing_strength: float) -> np.ndarray: + def project_mip_x_rotated( + data: np.ndarray, theta: float, depth_cueing_strength: float + ) -> np.ndarray: """Compute rotated MIP along X using NumPy. Output shape is (D, H).""" D, H, W = data.shape diag = int(np.ceil(np.sqrt(W**2 + H**2))) - + cx = (W - 1) / 2.0 cy = (H - 1) / 2.0 cos_t = np.cos(theta) sin_t = np.sin(theta) - + u = np.arange(diag, dtype=np.float32) - (diag - 1) / 2.0 - + y_out = np.arange(H)[:, np.newaxis] v = y_out - cy - + yr = np.round(cy + v * cos_t - u * sin_t).astype(np.int32) xr = np.round(cx + v * sin_t + u * cos_t).astype(np.int32) - + mask = (xr >= 0) & (xr < W) & (yr >= 0) & (yr < H) - + xr_clipped = np.clip(xr, 0, W - 1) yr_clipped = np.clip(yr, 0, H - 1) - - vals = data[np.arange(D)[np.newaxis, :, np.newaxis], yr_clipped[:, np.newaxis, :], xr_clipped[:, np.newaxis, :]] - + + vals = data[ + np.arange(D)[np.newaxis, :, np.newaxis], + yr_clipped[:, np.newaxis, :], + xr_clipped[:, np.newaxis, :], + ] + if depth_cueing_strength > 0.0: - factors = 1.0 - depth_cueing_strength * (np.arange(diag, dtype=np.float32) / max(1.0, diag - 1))[np.newaxis, np.newaxis, :] + factors = ( + 1.0 + - depth_cueing_strength + * (np.arange(diag, dtype=np.float32) / max(1.0, diag - 1))[ + np.newaxis, np.newaxis, : + ] + ) vals = vals * factors - - fill_value = np.iinfo(data.dtype).min if np.issubdtype(data.dtype, np.integer) else -np.inf + + fill_value = ( + np.iinfo(data.dtype).min + if np.issubdtype(data.dtype, np.integer) + else -np.inf + ) vals = np.where(mask[:, np.newaxis, :], vals, fill_value) - + return np.max(vals, axis=2).T.astype(data.dtype) @@ -409,14 +472,26 @@ def compute_mip_projection( strength = depth_cueing_strength if depth_cueing else 0.0 axis_upper = axis.upper() theta = np.deg2rad(rotation_angle) - + use_rotation = abs(theta) > 1e-5 - + if axis_upper == "Z": - return project_mip_z_rotated(data, theta, strength) if use_rotation else project_mip_z(data, strength) + return ( + project_mip_z_rotated(data, theta, strength) + if use_rotation + else project_mip_z(data, strength) + ) elif axis_upper == "Y": - return project_mip_y_rotated(data, theta, strength) if use_rotation else project_mip_y(data, strength) + return ( + project_mip_y_rotated(data, theta, strength) + if use_rotation + else project_mip_y(data, strength) + ) elif axis_upper == "X": - return project_mip_x_rotated(data, theta, strength) if use_rotation else project_mip_x(data, strength) + return ( + project_mip_x_rotated(data, theta, strength) + if use_rotation + else project_mip_x(data, strength) + ) else: raise ValueError(f"Invalid projection axis: {axis}") diff --git a/tests/test_gui.py b/tests/test_gui.py index d4bdac71..402c0743 100644 --- a/tests/test_gui.py +++ b/tests/test_gui.py @@ -1,5 +1,6 @@ import os import pytest +import platform import threading import numpy as np import SimpleITK as sitk @@ -244,6 +245,7 @@ def test_cli_boot_sequence_logic(headless_gui_app, synthetic_volume_factory): assert groups[0] == groups[1] assert groups[0] > 0 +@pytest.mark.skipif(platform.system() == "Windows", reason="Unix-specific test") def test_gui_interaction_modifiers(headless_gui_app, monkeypatch): """Verifies that holding the Ctrl key changes the mouse scroll from Slicing to Zooming.""" import sys @@ -1061,6 +1063,7 @@ def test_gui_per_image_last_tool_memory(headless_gui_app, synthetic_volume_facto assert vs1_id not in gui.last_tab_for_image +@pytest.mark.skipif(platform.system() == "Windows", reason="Unix-specific test") def test_gui_active_image_path_shown_in_menu_bar(headless_gui_app, synthetic_volume_factory): """Verifies the centered label in the top menu bar reflects the active viewer's full file path, updates on viewer switch, and clears when the diff --git a/tests/test_mip_integration.py b/tests/test_mip_integration.py index 51f3a94d..4094e242 100644 --- a/tests/test_mip_integration.py +++ b/tests/test_mip_integration.py @@ -1,6 +1,13 @@ from vvv.utils import ViewMode +import pytest +try: + import intel_openmp + HAS_INTEL_OPENMP = True +except ImportError: + HAS_INTEL_OPENMP = False +@pytest.mark.skipif(not HAS_INTEL_OPENMP, reason="Requires Intel OpenMP") def test_mip_integration(headless_gui_app): controller, gui, viewer, base_id = headless_gui_app @@ -267,7 +274,7 @@ def test_mip_sync_propagation(headless_gui_app): # Clean up sync link controller.sync.unlink_all() - +@pytest.mark.skipif(not HAS_INTEL_OPENMP, reason="Requires Intel OpenMP") def test_mip_fusion_precompute(headless_gui_app): import time controller, gui, viewer, base_id = headless_gui_app diff --git a/tests/test_settings.py b/tests/test_settings.py index caac6d7d..6af1ee9c 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -1,4 +1,5 @@ import platform +import pytest import unittest from typing import cast from vvv.core.controller import Controller @@ -50,7 +51,7 @@ def test_add_recent_file_absolute(self): expected_list = json.dumps([os.path.abspath("d1.dcm"), os.path.abspath("d2.dcm")]) self.assertEqual(recent[0], expected_list) - @unittest.skipIf(platform.system() == "Windows", "Unix-specific test") + @pytest.mark.skipif(platform.system() == "Windows", reason="Unix-specific test") def test_resolve_recent_path_home_users(self): # Using a file that is known to exist inside the repository import os