From 13ba71fb26789a21fa9fc9354bf62d0ed8d8f2fe Mon Sep 17 00:00:00 2001 From: imagene-shahar Date: Mon, 26 May 2025 19:58:02 +0300 Subject: [PATCH] Build libisyntax in CI --- .github/workflows/ci.yml | 36 ++++++ .gitignore | 1 + python_isyntax/LICENSE.txt | 24 ++++ python_isyntax/README.md | 17 +++ python_isyntax/pyproject.toml | 25 ++++ python_isyntax/python_isyntax/__init__.py | 9 ++ python_isyntax/python_isyntax/isyntax.py | 145 ++++++++++++++++++++++ python_isyntax/python_isyntax/lib.py | 108 ++++++++++++++++ python_isyntax/tests/test_basic.py | 17 +++ 9 files changed, 382 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 python_isyntax/LICENSE.txt create mode 100644 python_isyntax/README.md create mode 100644 python_isyntax/pyproject.toml create mode 100644 python_isyntax/python_isyntax/__init__.py create mode 100644 python_isyntax/python_isyntax/isyntax.py create mode 100644 python_isyntax/python_isyntax/lib.py create mode 100644 python_isyntax/tests/test_basic.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..2e44f26 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,36 @@ +name: CI +on: [push, pull_request] + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.9", "3.10", "3.11"] + steps: + - uses: actions/checkout@v3 + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install Pillow pytest build + - name: Install build tools + run: | + sudo apt-get update + sudo apt-get install -y ninja-build meson + - name: Build libisyntax + run: | + meson setup build + meson compile -C build + cp build/libisyntax.so python_isyntax/python_isyntax/ + - name: Run tests + run: pytest python_isyntax/tests + - name: Build wheel + run: python -m build --wheel --outdir dist + - name: Publish package + uses: pypa/gh-action-pypi-publish@release/v1 + with: + password: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index e916b79..a6ca717 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,4 @@ build/ *.isyntax *.json *.xml +__pycache__/ diff --git a/python_isyntax/LICENSE.txt b/python_isyntax/LICENSE.txt new file mode 100644 index 0000000..4365e29 --- /dev/null +++ b/python_isyntax/LICENSE.txt @@ -0,0 +1,24 @@ +BSD 2-Clause License + +Copyright (c) 2019-2023, Pieter Valkema + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/python_isyntax/README.md b/python_isyntax/README.md new file mode 100644 index 0000000..06a9df7 --- /dev/null +++ b/python_isyntax/README.md @@ -0,0 +1,17 @@ +# python-isyntax + +Pythonic wrapper around the libisyntax C library. + +The wheel published on GitHub includes a precompiled ``libisyntax`` shared +library so you do not need to build it yourself. + +``` +from python_isyntax import Isyntax + +with Isyntax("sample.isyntax") as img: + print("Levels:", img.level_count, img.level_dimensions) + region = img.read_region((0, 0), level=1, size=(512, 512)) + region.save("patch.png") + thumb = img.get_thumbnail((256, 256)) + thumb.show() +``` diff --git a/python_isyntax/pyproject.toml b/python_isyntax/pyproject.toml new file mode 100644 index 0000000..3d5c6d3 --- /dev/null +++ b/python_isyntax/pyproject.toml @@ -0,0 +1,25 @@ +[build-system] +requires = ["setuptools>=61", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "python-isyntax" +version = "0.1.0" +description = "Pythonic wrapper around libisyntax" +authors = [{name = "Imagene", email = "info@example.com"}] +requires-python = ">=3.8" +readme = "README.md" +license = {file = "LICENSE.txt"} + +[project.optional-dependencies] +test = ["pytest", "Pillow"] + +[tool.setuptools] +package-dir = {"" = "python_isyntax"} +include-package-data = true + +[tool.setuptools.packages.find] +where = ["python_isyntax"] + +[tool.setuptools.package-data] +python_isyntax = ["*.so", "*.dll", "*.dylib"] diff --git a/python_isyntax/python_isyntax/__init__.py b/python_isyntax/python_isyntax/__init__.py new file mode 100644 index 0000000..659004d --- /dev/null +++ b/python_isyntax/python_isyntax/__init__.py @@ -0,0 +1,9 @@ +"""High-level Python wrapper for libisyntax. + +This package provides the :class:`Isyntax` class which mimics the OpenSlide +interface for reading Philips iSyntax files. +""" + +from .isyntax import Isyntax + +__all__ = ["Isyntax"] diff --git a/python_isyntax/python_isyntax/isyntax.py b/python_isyntax/python_isyntax/isyntax.py new file mode 100644 index 0000000..b580e1b --- /dev/null +++ b/python_isyntax/python_isyntax/isyntax.py @@ -0,0 +1,145 @@ +"""High-level wrapper for libisyntax.""" + +from __future__ import annotations + +import ctypes +from typing import Dict, List, Tuple +from PIL import Image + +from .lib import LIB + + +class IsyntaxError(Exception): + """Base exception for iSyntax errors.""" + + +class Isyntax: + """Represents an iSyntax whole-slide image.""" + + def __init__(self, filename: str) -> None: + self._filename = filename.encode() + self._handle = ctypes.c_void_p() + err = LIB.lib.libisyntax_init() + if err != LIB.LIBISYNTAX_OK: + raise IsyntaxError(f"libisyntax_init failed with code {err}") + err = LIB.lib.libisyntax_open(self._filename, 0, ctypes.byref(self._handle)) + if err != LIB.LIBISYNTAX_OK: + raise IsyntaxError(f"libisyntax_open failed with code {err}") + self._cache = ctypes.c_void_p() + LIB.lib.libisyntax_cache_create(None, 128, ctypes.byref(self._cache)) + LIB.lib.libisyntax_cache_inject(self._cache, self._handle) + self._closed = False + + self._wsi_image = LIB.lib.libisyntax_get_wsi_image(self._handle) + + # Context manager ----------------------------------------------------- + def __enter__(self) -> "Isyntax": + return self + + def __exit__(self, exc_type, exc, tb) -> None: # noqa: D401 + self.close() + + def __del__(self) -> None: # noqa: D401 + self.close() + + # --------------------------------------------------------------------- + def close(self) -> None: + if not self._closed: + if self._cache: + LIB.lib.libisyntax_cache_destroy(self._cache) + self._cache = ctypes.c_void_p() + if self._handle: + LIB.lib.libisyntax_close(self._handle) + self._handle = ctypes.c_void_p() + self._closed = True + + # Properties ---------------------------------------------------------- + @property + def level_count(self) -> int: + """Number of resolution levels.""" + return LIB.lib.libisyntax_image_get_level_count(self._wsi_image) + + @property + def level_dimensions(self) -> List[Tuple[int, int]]: + """Dimensions for all levels.""" + dims = [] + for i in range(self.level_count): + level = LIB.lib.libisyntax_image_get_level(self._wsi_image, i) + width = LIB.lib.libisyntax_level_get_width(level) + height = LIB.lib.libisyntax_level_get_height(level) + dims.append((width, height)) + return dims + + @property + def properties(self) -> Dict[str, str]: + """Dictionary of slide properties.""" + # libisyntax does not yet expose metadata via API; return minimal stub + return {"vendor": "Philips", "filename": self._filename.decode()} + + @property + def associated_images(self) -> Dict[str, Image.Image]: + """Return associated images such as label and macro.""" + images: Dict[str, Image.Image] = {} + width = ctypes.c_int32() + height = ctypes.c_int32() + buf = ctypes.POINTER(ctypes.c_uint32)() + err = LIB.lib.libisyntax_read_label_image(self._handle, ctypes.byref(width), ctypes.byref(height), ctypes.byref(buf), 1) + if err == LIB.LIBISYNTAX_OK: + data = ctypes.cast(buf, ctypes.POINTER(ctypes.c_uint32 * (width.value * height.value))).contents + img = Image.frombuffer("RGBA", (width.value, height.value), data, "raw", "BGRA", 0, 1) + images["label"] = img + err = LIB.lib.libisyntax_read_macro_image(self._handle, ctypes.byref(width), ctypes.byref(height), ctypes.byref(buf), 1) + if err == LIB.LIBISYNTAX_OK: + data = ctypes.cast(buf, ctypes.POINTER(ctypes.c_uint32 * (width.value * height.value))).contents + img = Image.frombuffer("RGBA", (width.value, height.value), data, "raw", "BGRA", 0, 1) + images["macro"] = img + return images + + # Methods ------------------------------------------------------------- + def read_region(self, location: Tuple[int, int], level: int, size: Tuple[int, int]) -> Image.Image: + """Read an image region. + + Args: + location: (x, y) tuple in level 0 reference frame. + level: Resolution level index. + size: (width, height) of region to extract. + + Returns: + PIL Image containing the requested region. + """ + x, y = location + width, height = size + buffer_size = width * height + buf = (ctypes.c_uint32 * buffer_size)() + err = LIB.lib.libisyntax_read_region( + self._handle, + self._cache, + level, + x, + y, + width, + height, + buf, + 1, + ) + if err != LIB.LIBISYNTAX_OK: + raise IsyntaxError(f"read_region failed with code {err}") + data = bytes(buf) + img = Image.frombuffer("RGBA", (width, height), data, "raw", "BGRA", 0, 1) + return img + + def get_thumbnail(self, size: Tuple[int, int]) -> Image.Image: + """Return thumbnail image of approximate size.""" + best_level = self.get_best_level_for_downsample(max(self.level_dimensions[0][0] / size[0], 1)) + dims = self.level_dimensions[best_level] + thumb = self.read_region((0, 0), best_level, dims) + thumb.thumbnail(size) + return thumb + + def get_best_level_for_downsample(self, downsample: float) -> int: + """Choose the best level for a given downsample factor.""" + for i, dim in enumerate(self.level_dimensions): + level_downsample = self.level_dimensions[0][0] / dim[0] + if level_downsample >= downsample: + return i + return self.level_count - 1 diff --git a/python_isyntax/python_isyntax/lib.py b/python_isyntax/python_isyntax/lib.py new file mode 100644 index 0000000..6385568 --- /dev/null +++ b/python_isyntax/python_isyntax/lib.py @@ -0,0 +1,108 @@ +"""ctypes bindings for libisyntax.""" + +from __future__ import annotations + +import ctypes +import os +import sys +import importlib.resources as resources +from ctypes import c_int32, c_int64, c_uint32, c_char_p, c_float, POINTER + + +class LibISyntax: + """Thin wrapper around the libisyntax shared library.""" + + def __init__(self, library_path: str | None = None) -> None: + if library_path is None: + libname = { + "darwin": "libisyntax.dylib", + "win32": "libisyntax.dll", + }.get(sys.platform, "libisyntax.so") + env_path = os.environ.get("LIBISYNTAX_PATH") + if env_path: + library_path = env_path + else: + try: + library_path = str(resources.files(__package__).joinpath(libname)) + except Exception: + library_path = libname + self.lib = ctypes.CDLL(library_path) + self._setup() + + def _setup(self) -> None: + lib = self.lib + + self.LIBISYNTAX_OK = 0 + self.LIBISYNTAX_FATAL = 1 + self.LIBISYNTAX_INVALID_ARGUMENT = 2 + + lib.libisyntax_init.restype = c_int32 + + lib.libisyntax_open.argtypes = [c_char_p, c_int32, POINTER(ctypes.c_void_p)] + lib.libisyntax_open.restype = c_int32 + + lib.libisyntax_close.argtypes = [ctypes.c_void_p] + lib.libisyntax_close.restype = None + + lib.libisyntax_get_wsi_image.argtypes = [ctypes.c_void_p] + lib.libisyntax_get_wsi_image.restype = ctypes.c_void_p + + lib.libisyntax_image_get_level_count.argtypes = [ctypes.c_void_p] + lib.libisyntax_image_get_level_count.restype = c_int32 + + lib.libisyntax_image_get_level.argtypes = [ctypes.c_void_p, c_int32] + lib.libisyntax_image_get_level.restype = ctypes.c_void_p + + lib.libisyntax_level_get_width.argtypes = [ctypes.c_void_p] + lib.libisyntax_level_get_width.restype = c_int32 + + lib.libisyntax_level_get_height.argtypes = [ctypes.c_void_p] + lib.libisyntax_level_get_height.restype = c_int32 + + lib.libisyntax_read_region.argtypes = [ + ctypes.c_void_p, + ctypes.c_void_p, + c_int32, + c_int64, + c_int64, + c_int64, + c_int64, + POINTER(c_uint32), + c_int32, + ] + lib.libisyntax_read_region.restype = c_int32 + + lib.libisyntax_read_label_image.argtypes = [ + ctypes.c_void_p, + POINTER(c_int32), + POINTER(c_int32), + POINTER(POINTER(c_uint32)), + c_int32, + ] + lib.libisyntax_read_label_image.restype = c_int32 + + lib.libisyntax_read_macro_image.argtypes = [ + ctypes.c_void_p, + POINTER(c_int32), + POINTER(c_int32), + POINTER(POINTER(c_uint32)), + c_int32, + ] + lib.libisyntax_read_macro_image.restype = c_int32 + + lib.libisyntax_get_tile_width.argtypes = [ctypes.c_void_p] + lib.libisyntax_get_tile_width.restype = c_int32 + lib.libisyntax_get_tile_height.argtypes = [ctypes.c_void_p] + lib.libisyntax_get_tile_height.restype = c_int32 + + lib.libisyntax_cache_create.argtypes = [c_char_p, c_int32, POINTER(ctypes.c_void_p)] + lib.libisyntax_cache_create.restype = c_int32 + + lib.libisyntax_cache_inject.argtypes = [ctypes.c_void_p, ctypes.c_void_p] + lib.libisyntax_cache_inject.restype = c_int32 + + lib.libisyntax_cache_destroy.argtypes = [ctypes.c_void_p] + lib.libisyntax_cache_destroy.restype = None + + +LIB = LibISyntax() diff --git a/python_isyntax/tests/test_basic.py b/python_isyntax/tests/test_basic.py new file mode 100644 index 0000000..6d350ae --- /dev/null +++ b/python_isyntax/tests/test_basic.py @@ -0,0 +1,17 @@ +import os +import pytest +from python_isyntax import Isyntax + +SAMPLE = os.path.join(os.path.dirname(__file__), "sample.isyntax") + +@pytest.mark.skipif(not os.path.exists(SAMPLE), reason="sample file missing") +def test_open_slide(): + with Isyntax(SAMPLE) as img: + assert img.level_count > 0 + assert len(img.level_dimensions) == img.level_count + +@pytest.mark.skipif(not os.path.exists(SAMPLE), reason="sample file missing") +def test_read_region(): + with Isyntax(SAMPLE) as img: + region = img.read_region((0, 0), 0, (128, 128)) + assert region.size == (128, 128)