Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
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
- name: Run tests
run: pytest python_isyntax/tests
- name: Build package
run: |
python -m pip install build
cd python_isyntax
python -m build
- name: Publish to GitHub Packages
uses: pypa/gh-action-pypi-publish@v1
with:
user: __token__
password: ${{ secrets.GITHUB_TOKEN }}
repository-url: "https://pypi.pkg.github.com/${{ github.repository_owner }}"
packages-dir: python_isyntax/dist
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,4 @@ build/
*.isyntax
*.json
*.xml
__pycache__/
24 changes: 24 additions & 0 deletions python_isyntax/LICENSE.txt
Original file line number Diff line number Diff line change
@@ -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.
14 changes: 14 additions & 0 deletions python_isyntax/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# python-isyntax

Pythonic wrapper around the libisyntax C library.

```
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()
```
15 changes: 15 additions & 0 deletions python_isyntax/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
[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"]
9 changes: 9 additions & 0 deletions python_isyntax/python_isyntax/__init__.py
Original file line number Diff line number Diff line change
@@ -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"]
145 changes: 145 additions & 0 deletions python_isyntax/python_isyntax/isyntax.py
Original file line number Diff line number Diff line change
@@ -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
95 changes: 95 additions & 0 deletions python_isyntax/python_isyntax/lib.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
"""ctypes bindings for libisyntax."""

from __future__ import annotations

import ctypes
import os
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:
library_path = os.environ.get("LIBISYNTAX_PATH", "libisyntax.so")
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()
17 changes: 17 additions & 0 deletions python_isyntax/tests/test_basic.py
Original file line number Diff line number Diff line change
@@ -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)
Loading