Skip to content

Commit

Permalink
Initial implementation of dolomite's generics to read and save spatia…
Browse files Browse the repository at this point in the history
…l objects (#1)
  • Loading branch information
jkanche authored Feb 1, 2025
1 parent 26b510b commit 93da839
Show file tree
Hide file tree
Showing 24 changed files with 444 additions and 197 deletions.
13 changes: 8 additions & 5 deletions .github/workflows/publish-pypi.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@ on:
jobs:
build:
runs-on: ubuntu-latest
permissions:
id-token: write
repository-projects: write
contents: write
pages: write

steps:
- uses: actions/checkout@v4
Expand Down Expand Up @@ -45,8 +50,6 @@ jobs:
run: |
python -m tox -e clean,build
- name: Publish package
uses: pypa/[email protected]
with:
user: __token__
password: ${{ secrets.PYPI_API_TOKEN }}
# This uses the trusted publisher workflow so no token is required.
- name: Publish to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
6 changes: 2 additions & 4 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
# Changelog

## Version 0.1 (development)
## Version 0.1

- Feature A added
- FIX: nasty bug #1729 fixed
- add your changes here!
- Initial version of the readers and writers for `SpatialExperiment`.
86 changes: 77 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,21 +1,89 @@
[![PyPI-Server](https://img.shields.io/pypi/v/dolomite-spatial.svg)](https://pypi.org/project/dolomite-spatial/)
![Unit tests](https://github.com/BiocPy/dolomite-spatial/actions/workflows/pypi-test.yml/badge.svg)
![Unit tests](https://github.com/BiocPy/dolomite-spatial/actions/workflows/run-tests.yml/badge.svg)

# dolomite-spatial
# Save and write `SpatialExperiment`'s in Python

> Add a short description here!
## Introduction

A longer description of your project goes here...
The **dolomite-spatial** package is the Python counterpart to the [**alabaster.spatial**](https://github.com/ArtifactDB/alabaster.spatial) R package,
providing methods for saving/reading `SpatialExperiment` objects within the [**dolomite** framework](https://github.com/ArtifactDB/dolomite-base).

## Install
## Quick start

To get started, install the package from [PyPI](https://pypi.org/project/dolomite-spatial/)
Let's mock up a `SpatialExperiment` that contains reduced dimensions and alternative experiments,

```bash
pip install dolomite-spatial
```python
from spatialexperiment import SpatialExperiment, construct_spatial_image_class

import biocframe
import numpy as np

spe = SpatialExperiment(
assays={"counts": np.random.rand(1000, 200)},
row_data=biocframe.BiocFrame(
{"foo": np.random.rand(1000), "bar": np.random.rand(1000)}
),
column_data=biocframe.BiocFrame(
{"whee": np.random.rand(200), "stuff": np.random.rand(200), "sample_id": ["sample_1", "sample_2"] * 100}
),
reduced_dims={"tsnooch": np.random.rand(200, 4)},
spatial_coords = np.array(
[
np.random.uniform(low=0.0, high=100.0, size=200),
np.random.uniform(low=0.0, high=100.0, size=200)
]
).transpose(),
img_data = biocframe.BiocFrame({
"sample_id": ["sample_1", "sample_1", "sample_2"],
"image_id": ["aurora", "dice", "desert"],
"data": [
construct_spatial_image_class("biocpy/spatialexperiment/tests/images/sample_image1.jpg"),
construct_spatial_image_class("biocpy/spatialexperiment/tests/images/sample_image2.png"),
construct_spatial_image_class("biocpy/spatialexperiment/tests/images/sample_image3.jpg"),
],
"scale_factor": [1, 1, 1],
})
)

print(spe)
```

Now we can save it:

```python
from dolomite_base import save_object
import dolomite_spatial
import os
from tempfile import mkdtemp

path = os.path.join(mkdtemp(), "test")
save_object(spe, path)
```

<!-- biocsetup-notes -->
And load it again, e,g., in a new session:

```python
from dolomite_base import read_object

roundtrip = read_object(path)
print(roundtrip)
```
## output
class: SpatialExperiment
dimensions: (1000, 200)
assays(1): ['counts']
row_data columns(2): ['foo', 'bar']
row_names(0):
column_data columns(3): ['whee', 'stuff', 'sample_id']
column_names(0):
main_experiment_name:
reduced_dims(1): ['tsnooch']
alternative_experiments(0): []
row_pairs(0): []
column_pairs(0): []
metadata(0):
spatial_coords columns(0): []
img_data columns(4): ['sample_id', 'image_id', 'data', 'scale_factor']

## Note

Expand Down
13 changes: 9 additions & 4 deletions setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,17 @@

[metadata]
name = dolomite-spatial
description = Add a short description here!
description = Read and write Spatial Experiments to takane representations
author = Jayaram Kancherla
author_email = [email protected]
license = MIT
license_files = LICENSE.txt
long_description = file: README.md
long_description_content_type = text/markdown; charset=UTF-8; variant=GFM
url = https://github.com/pyscaffold/pyscaffold/
url = https://github.com/ArtifactDB/dolomite-spatial
# Add here related links, for example:
project_urls =
Documentation = https://pyscaffold.org/
Documentation = https://github.com/ArtifactDB/dolomite-spatial
# Source = https://github.com/pyscaffold/pyscaffold/
# Changelog = https://pyscaffold.org/en/latest/changelog.html
# Tracker = https://github.com/pyscaffold/pyscaffold/issues
Expand All @@ -41,14 +41,19 @@ package_dir =
=src

# Require a min/specific Python version (comma-separated conditions)
# python_requires = >=3.8
python_requires = >=3.9

# 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"
dolomite-base
dolomite-sce
dolomite-ranges>=0.2.1
spatialexperiment>=0.0.4
h5py


[options.packages.find]
Expand Down
5 changes: 4 additions & 1 deletion src/dolomite_spatial/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,12 @@

try:
# Change here if project is renamed and does not equal the package name
dist_name = "dolomite-spatial"
dist_name = "dolomite-spe"
__version__ = version(dist_name)
except PackageNotFoundError: # pragma: no cover
__version__ = "unknown"
finally:
del version, PackageNotFoundError

from .read_spatial_experiment import read_spatial_experiment
from .save_spatial_experiment import save_spatial_experiment
95 changes: 95 additions & 0 deletions src/dolomite_spatial/read_spatial_experiment.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import json
import os

import dolomite_base as dl
import dolomite_sce as dlsce
import h5py
from biocframe import BiocFrame
from dolomite_base.read_object import read_object_registry
from spatialexperiment import SpatialExperiment, construct_spatial_image_class

read_object_registry["spatial_experiment"] = "dolomite_spatial.read_spatial_experiment"


def read_spatial_experiment(path: str, metadata: dict, **kwargs) -> SpatialExperiment:
"""Load a
:py:class:`~spatialexperiment.SpatialExperiment.SpatialExperiment`
from its on-disk representation.
This method should generally not be called directly but instead be invoked by
:py:meth:`~dolomite_base.read_object.read_object`.
Args:
path:
Path to the directory containing the object.
metadata:
Metadata for the object.
kwargs:
Further arguments.
Returns:
A
:py:class:`~spatialexperiment.SpatialExperiment.SpatialExperiment`
with file-backed arrays in the assays.
"""
sce = dlsce.read_single_cell_experiment(path, metadata=metadata, **kwargs)

spe = SpatialExperiment(
assays=sce.get_assays(),
row_data=sce.get_row_data(),
column_data=sce.get_column_data(),
row_ranges=sce.get_row_ranges(),
metadata=sce.get_metadata(),
main_experiment_name=sce.get_main_experiment_name(),
reduced_dims=sce.get_reduced_dims(),
alternative_experiments=sce.get_alternative_experiments(),
)

_sp_coords_path = os.path.join(path, "coordinates")
if os.path.exists(_sp_coords_path):
_coords = dl.alt_read_object(_sp_coords_path, **kwargs)
spe = spe.set_spatial_coordinates(_coords)
else:
raise FileNotFoundError(f"cannot find spatial coordinates at {path}.")

_img_path = os.path.join(path, "images")
if os.path.exists(_img_path):
with h5py.File(os.path.join(_img_path, "mapping.h5"), "r") as handle:
ghandle = handle["spatial_experiment"]

sample_names = dl.load_vector_from_hdf5(ghandle["sample_names"], expected_type=str, report_1darray=True)
column_samples = dl.load_vector_from_hdf5(ghandle["column_samples"], expected_type=int, report_1darray=True)

image_samples = dl.load_vector_from_hdf5(ghandle["image_samples"], expected_type=int, report_1darray=True)

image_ids = dl.load_vector_from_hdf5(ghandle["image_ids"], expected_type=str, report_1darray=True)
image_formats = dl.load_vector_from_hdf5(ghandle["image_formats"], expected_type=str, report_1darray=True)

image_scale_factors = dl.load_vector_from_hdf5(
ghandle["image_scale_factors"], expected_type=float, report_1darray=True
)

# replace column names; just in case
spe = spe.set_column_data(spe.get_column_data().set_column("sample_id", sample_names[column_samples]))

image_data = []
if len(image_samples) > 0:
for i, _ in enumerate(image_samples):
# TODO: write reader for SpatialImage class
image_data.append(construct_spatial_image_class(os.path.join(_img_path, f"{i}.{str(image_formats[i]).lower()}")))

_image_frame = BiocFrame(
{
"sample_id": sample_names[image_samples],
"image_id": image_ids,
"data": image_data,
"scale_factor": image_scale_factors,
}
)

# add image data
spe = spe.set_image_data(_image_frame)

return spe
46 changes: 46 additions & 0 deletions src/dolomite_spatial/save_image.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import os
import shutil
from typing import Optional

from PIL import Image


def save_image(src: str, directory: str, i: int) -> Optional[str]:
"""Save an image file with proper format handling.
Args:
src:
Source path of the image.
directory:
Directory to save the image to.
i:
Index of the image.
Returns:
Format of the image ('PNG' or 'TIFF') or None if format not supported.
"""
with Image.open(src) as img:
format = img.format

if format == "PNG":
suffix = "png"
elif format == "TIFF":
suffix = "tif"
else:
return None

dest = os.path.join(directory, f"{i}.{suffix}")

try:
# Try to create a hard link first
os.link(src, dest)
except OSError:
# If linking fails, try to copy the file
try:
shutil.copy2(src, dest)
except Exception as e:
raise RuntimeError(f"failed to copy from '{src}' to '{dest}': {str(e)}")

return format
Loading

0 comments on commit 93da839

Please sign in to comment.