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
70 changes: 70 additions & 0 deletions .github/workflows/publish-pkg.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
name: Test and Publish PiPY Package

on:
push:
tags:
- 'v*'

jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.9", "3.10", "3.11"]

steps:
- name: Checkout Repository
uses: actions/checkout@v4

- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v4
with:
python-version: ${{ matrix.python-version }}

- name: Cache pip dependencies
uses: actions/cache@v3
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements*.txt', '**/setup.py', '**/pyproject.toml') }}

- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -e .[dev] || pip install -e .
pip install pytest

- name: Run tests
run: |
pytest --tb=short -v

build-and-publish:
needs: test # Only run if tests pass
runs-on: ubuntu-latest

steps:
- name: Checkout Repository
uses: actions/checkout@v4

- name: Set up Python 3.11
uses: actions/setup-python@v4
with:
python-version: 3.11

- name: Install build dependencies
run: |
python -m pip install --upgrade pip build twine

- name: Build distributions
run: |
python -m build

- name: Verify build
run: |
python -m twine check dist/*

- name: Publish to PyPI
env:
TWINE_USERNAME: __token__
TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }}
run: |
python -m twine upload dist/*
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ spinenet-venv
spinenet/weights/*
tutorials/example_scans/*
tutorials/results/*

.python-version

# Standard Python GitIgnore
# Byte-compiled / optimized / DLL files
Expand Down
2 changes: 1 addition & 1 deletion .readthedocs.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ version: 2
build:
os: ubuntu-20.04
tools:
python: "3.8"
python: "3.9"
# You can also specify other tool versions:
# nodejs: "16"
# rust: "1.55"
Expand Down
21 changes: 19 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,26 @@


# SpineNet - PyPI Package

[![PyPI version](https://badge.fury.io/py/spinenet.svg)](https://badge.fury.io/py/spinenet)
[![Python](https://img.shields.io/pypi/pyversions/spinenet.svg)](https://pypi.org/project/spinenet/)
[![Tests](https://github.com/mariamonzon/SpineNet/workflows/Test%20and%20Publish%20PiPY%20Package/badge.svg)](https://github.com/mariamonzon/SpineNet/actions)



SpineNet is now available as a PyPI package for easy installation and use!
Install SpineNet directly from PyPI using pip:

```bash
pip install spinenet
```

<p align="center">
<img width='100%' src='spinenetlogo.png'>
</p>

**DISCLAIMER: SpineNet is not a diagnostics tool nor a medical device. It should only be used for research.**


## Introduction

SpineNet is automated software for analysing clinical spinal MRI scans. Current functionality includes:
Expand Down Expand Up @@ -81,7 +98,7 @@ We are grateful to the providers of the example scans used in the tutorials (ori
## Coming Soon

- [x] Documentation
- [ ] Pip installation
- [x] Pip installation
- [ ] In-depth tutorials on finetuning SpineNet's grading network

We are always looking to extend SpineNet to make it a more useful tool. If you have a request for features please get in touch with us.
61 changes: 61 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,3 +1,63 @@
[project]
name = "spinenet"
version = "2.0.4"
description = "Automated software for analysing clinical spinal MRI scans."
readme = "README.md"
authors = [
{ name = "Maria Monzon", email = "[email protected]" },
{ name = "Rhydian Windsor", email = "" }
]
keywords = ["medical-imaging", "deep-learning", "spine", "MRI", "segmentation"]
classifiers = [
"Development Status :: 3 - Alpha",
"Intended Audience :: Science/Research",
"Topic :: Scientific/Engineering :: Medical Science Apps.",
"License :: OSI Approved :: MIT License",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",

]
requires-python = ">=3.9,<3.12"
dependencies = [
"opencv-python>=4.5.0,<4.8.0.76", # Exclude problematic version
"numpy<2",
"torch>=2.0.0,<=2.5.0",
"torchvision>=0.15.0",
"numpy>=1.20.0",
"scipy>=1.7.0",
"scikit-image>=0.19.0",
"nibabel>=3.2.0",
"pandas>=1.3.0",
"pydicom",
"shapely",
"matplotlib>=3.5.0",
"requests",
"tqdm"
]

[project.optional-dependencies]
dev = [
"pytest>=7.0.0",
"pytest-cov>=4.0.0",
"black>=22.0.0",
"ruff>=0.1.0",
"mypy>=1.0.0",
]
vis = [
"matplotlib>=3.5.0",
"seaborn>=0.11.0",
"plotly>=5.0.0",
]
[project.scripts]
spinenet = "spinenet:main"

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"


[tool.black]
line-length = 79
include = '\.pyi?$'
Expand All @@ -14,3 +74,4 @@ exclude = '''
| dist
)/
'''

3 changes: 2 additions & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ requests
scikit-image
scipy
shapely
torch
torch>=2.0.1,<=2.5.0
torchvision
tqdm
matplotlib
1 change: 1 addition & 0 deletions spinenet/.python-version
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This python version doesn't match that in the .readthedocs.yaml file (3.9)

Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
3.13
10 changes: 6 additions & 4 deletions spinenet/io/dicom_io.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import logging
import os
from typing import List, Tuple, Dict, Union, Any
import glob
Expand Down Expand Up @@ -74,17 +75,18 @@ def load_dicoms(
)
is_sagittal = is_sagittal_dicom_slice(dicom_file)
if not is_sagittal:
raise ValueError(
f"File at {paths[dicom_idx]} is not a sagittal dicom slice"
)
dicom_files.pop(dicom_idx)
#raise ValueError(
logging.warning( f"File at {paths[dicom_idx]} is not a sagittal dicom slice")
# sort slices by sagittal position
dicom_files = sorted(
dicom_files, key=lambda dicom_file: dicom_file.InstanceNumber
)


pixel_spacing = np.mean(
[np.array(dicom_file.PixelSpacing) for dicom_file in dicom_files]
)
, axis=0)
slice_thickness = np.mean(
[np.array(dicom_file.SliceThickness) for dicom_file in dicom_files]
)
Expand Down
9 changes: 7 additions & 2 deletions spinenet/utils/detect_and_group.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
import numpy as np
import matplotlib.pyplot as plt
import scipy.ndimage
from .scan_preprocessing import split_into_patches_exhaustive
from .scan_preprocessing import split_into_patches_exhaustive, split_into_patches_exhaustive_spacing
from .detection_post_processing import make_in_slice_detections


Expand Down Expand Up @@ -61,8 +61,13 @@ def detect_and_group(
polygons detected.
"""


# split the scan into different patches
# patches, transform_info_dicts = split_into_patches_exhaustive(
# scan, pixel_spacing=pixel_spacing, overlap_param=0.4, using_resnet=using_resnet
# )
# split the scan into different patches
patches, transform_info_dicts = split_into_patches_exhaustive(
patches, transform_info_dicts = split_into_patches_exhaustive_spacing(
scan, pixel_spacing=pixel_spacing, overlap_param=0.4, using_resnet=using_resnet
)
# group the detections made in each patch into slice level detections
Expand Down
39 changes: 38 additions & 1 deletion spinenet/utils/detection_post_processing.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,32 @@
from shapely.geometry import Polygon, Point


def validate_coordinates(coords, max_shape):
"""
Filter out coordinates that are negative or exceed image bounds.

Parameters
----------
coords : array-like, shape (N, 2)
List or array of coordinates to validate.
max_shape : tuple of int
Maximum allowed shape \(`height`, `width`\) for the coordinates.

Returns
-------
valid_coords : ndarray, shape (M, 2)
Array of valid coordinates within the image bounds.
"""
if len(coords) == 0:
return np.empty((0, 2))

coords = np.array(coords)
valid_mask = (
(coords[:, 0] >= 0) & (coords[:, 0] < max_shape[0]) &
(coords[:, 1] >= 0) & (coords[:, 1] < max_shape[1])
)
return coords[valid_mask]

def make_in_slice_detections(
detection_net,
patches,
Expand Down Expand Up @@ -133,6 +159,9 @@ def make_in_slice_detections(
points * patch_edge_len / 224
+ np.array([transform_info["y1"], transform_info["x1"]])
)
transformed_patch_corners = validate_coordinates(
transformed_patch_corners, scan_shape
)
all_corners["points"][corner_type].append(transformed_patch_corners)
arrows = np.zeros_like(points)
for idx, point in enumerate(points):
Expand Down Expand Up @@ -171,10 +200,12 @@ def make_in_slice_detections(
scan_centroid_channel /= centroid_channel_contributions
# detect the centroids
centroids = get_points(scan_centroid_channel, threshold=centroid_threshold)
centroids = validate_coordinates(centroids, scan_shape)

detection_polys = []
arrows = all_corners["arrows"]
corners = all_corners["points"]

# now loop through each detected centroid and find the closest displaced
# corner of each type
for centroid in centroids:
Expand Down Expand Up @@ -246,7 +277,13 @@ def make_in_slice_detections(
elif sum(missing_arrows) == 1:
for i, el in enumerate(missing_arrows):
if el:
detection_poly[i] = centroid + indiv_arrows[(i + 2) % 4]
new_corner = centroid + indiv_arrows[(i + 2) % 4]
# **VALIDATE NEW CORNER COORDINATE*
if not (0 <= new_corner[0] < scan_shape[0] and 0 <= new_corner[1] < scan_shape[1]):
# Get closest valid coordinate within image bounds
new_corner = np.clip(new_corner, [0, 0], np.array(scan_shape[:2]) - 1)
detection_poly[i] = new_corner

# # flip around poly to match form needed for matplotlib plotting
detection_polys.append([[i[1], i[0]] for i in detection_poly])

Expand Down
2 changes: 1 addition & 1 deletion spinenet/utils/gen_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -367,7 +367,7 @@ def poly2mask(vertex_row_coords, vertex_col_coords, shape):
fill_row_coords, fill_col_coords = draw.polygon(
vertex_row_coords, vertex_col_coords, shape
)
mask = np.zeros(shape, dtype=np.bool)
mask = np.zeros(shape, dtype=bool)
mask[fill_row_coords, fill_col_coords] = True
return mask

Expand Down
8 changes: 4 additions & 4 deletions spinenet/utils/label_verts.py
Original file line number Diff line number Diff line change
Expand Up @@ -245,18 +245,18 @@ def construct_input_to_context_model(vert_dicts, scan, pixel_spacing):
box_coords = np.asarray([vert_dict["average_polygon"] for vert_dict in vert_dicts])
y_centroids = (
np.asarray([np.mean(box_coord[:, 1]) for box_coord in box_coords])
* pixel_spacing
* pixel_spacing[1]
)
y_maxes = (
np.asarray([np.max(box_coord[:, 1]) for box_coord in box_coords])
* pixel_spacing
* pixel_spacing[1]
)
y_mins = (
np.asarray([np.min(box_coord[:, 1]) for box_coord in box_coords])
* pixel_spacing
* pixel_spacing[1]
)
widths = (y_maxes - y_mins) / 2
image_height = int(scan.shape[0] * pixel_spacing)
image_height = int(scan.shape[0] * pixel_spacing[0])
height_scaled_appearance_features = np.zeros((int(image_height), 24))

for i in range(len(vert_dicts)):
Expand Down
Loading