diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 0000000..83af2c3 --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,25 @@ +name: Docs + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install package and docs dependencies + run: | + pip install -e . + pip install mkdocs mkdocs-material "mkdocstrings[python]" mkdocs-gen-files mkdocs-literate-nav + + - name: Build docs + run: mkdocs build diff --git a/.readthedocs.yml b/.readthedocs.yml new file mode 100644 index 0000000..4995b0d --- /dev/null +++ b/.readthedocs.yml @@ -0,0 +1,17 @@ +version: 2 + +build: + os: ubuntu-24.04 + tools: + python: "3.12" + jobs: + post_install: + - pip install "mkdocs>=1.6" "mkdocs-material>=9.5" "mkdocstrings[python]>=0.25" + +mkdocs: + configuration: mkdocs.yml + +python: + install: + - method: pip + path: . diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..87c77ae --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,95 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +TPTBox is a Python package for processing BIDS-compliant medical imaging datasets (CT, MRI), with a focus on torso/spine analysis. It provides NIfTI I/O, reorientation/resampling, Points of Interest (POI) computation for vertebrae, 2D/3D snapshots, registration, and segmentation integrations (SPINEPS, nnU-Net). + +## Commands + +### Installation +```bash +pip install poetry +poetry install --with dev +``` + +### Running Tests +```bash +# All tests +pytest unit_tests/ + +# Single test file +pytest unit_tests/test_nii.py + +# Single test function +pytest unit_tests/test_nii.py::test_function_name + +# With coverage +coverage run --source=TPTBox -m pytest unit_tests/ +``` + +### Linting & Formatting +```bash +# Lint (auto-fix where possible) +ruff check . --fix + +# Format +ruff format . + +# Both (mirrors pre-commit behavior) +pre-commit run --all-files +``` + +### CI Linting (as run in GitHub Actions) +```bash +flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics +``` + +## Architecture + +### Core Abstractions + +**`NII`** (`TPTBox/core/nii_wrapper.py`) — Central class wrapping nibabel NIfTI images. Handles reorientation, resampling, affine transforms, boolean masking, and mathematical operations. Nearly all image operations in the package go through `NII`. Math operations live in `nii_wrapper_math.py` as mixins. + +**`POI`** (`TPTBox/core/poi.py`) — Points of Interest container mapping `(vertebra_id, subregion_id) → 3D coordinate`. Coordinates can be in voxel or world space. POI computation strategies live in `TPTBox/core/poi_fun/`. + +**`BIDS_FILE` / `BIDS_Global_info`** (`TPTBox/core/bids_files.py`) — BIDS dataset navigator. `BIDS_Global_info` scans a dataset root, while `BIDS_FILE` represents a single file parsed according to BIDS naming entities. Constants for BIDS key/value vocabulary are in `bids_constants.py`. + +**`Location` / `Vertebra_Instance`** (`TPTBox/core/vert_constants.py`) — Enum-like constants for vertebral anatomy (vertebra IDs, subregion labels). Used as keys into `POI` objects throughout the codebase. + +### Package Layout + +``` +TPTBox/ +├── core/ # NII, POI, BIDS parsing, numpy utilities, vertebra constants +│ └── poi_fun/ # POI calculation strategies and serialization +├── spine/ # Spine-specific: 2D snapshots, statistics (distances, angles) +├── segmentation/ # SPINEPS integration, nnU-Net inference, defacing +├── registration/ # ANTs rigid/deformable, DeepALI deep learning registration +├── mesh3D/ # 3D mesh generation and rendering from segmentations +├── stitching/ # Multi-station image stitching +└── logger/ # Logging infrastructure (Logger, Print_Logger, No_Logger) +``` + +Public API is re-exported from `TPTBox/__init__.py`. All major classes and utility functions are importable directly from `TPTBox`. + +### Key Relationships + +- `NII` + `POI` are used together constantly: compute POIs from segmentation `NII`s, then use POIs to guide further processing. +- `BIDS_Global_info` iterates subjects/sessions; each subject has a `Subject_Container` with `BIDS_FILE` entries; `BIDS_FILE.get_nii()` returns a `NII`. +- `vert_constants.py` defines the shared label space (`Location` enum) that ties segmentation labels to POI keys to snapshot rendering. +- External tool integrations (SPINEPS, nnU-Net, ANTs, DeepALI) are optional; their imports are guarded so the core works without them. + +### Test Layout + +Tests live in `unit_tests/` (not `TPTBox/tests/`). `TPTBox/tests/` contains test utilities and sample data (CT/MRI NIfTIs) used by the unit tests. Some generated test files are very large (>20K LOC) — they are autogenerated and should not be edited by hand. + +## Code Style + +- **Line length**: 140 characters +- **Formatter**: Ruff (Black-compatible, double quotes) +- **Target Python**: 3.10+ syntax, but the package supports 3.9–3.14 +- **Naming**: Ruff N-rules are largely ignored — mixed-case class/method names are acceptable in this codebase (medical domain conventions) +- **Complexity**: McCabe max=20; research code legitimately has deep branching +- `from __future__ import annotations` is used widely for forward references diff --git a/TPTBox/core/README.md b/TPTBox/core/README.md new file mode 100644 index 0000000..9c2887f --- /dev/null +++ b/TPTBox/core/README.md @@ -0,0 +1,97 @@ +# TPTBox Core + +The `core` subpackage is the foundation of TPTBox. It provides the three primary abstractions — +`NII`, `POI`, and `BIDS_FILE` — along with helper utilities for array operations and anatomical constants. + +## Key Classes and Functions + +### `nii_wrapper.py` — NIfTI image wrapper + +| Symbol | Description | +|---|---| +| `NII` | Wraps `nibabel.Nifti1Image`; the central image type throughout TPTBox | +| `NII.load(path, seg)` | Load a NIfTI file from disk (classmethod) | +| `NII.from_numpy(arr, affine, seg)` | Construct from a numpy array and affine matrix | +| `NII.reorient(axcodes_to)` | Reorient to a canonical axis code (e.g. `("R","A","S")`) | +| `NII.rescale(voxel_spacing)` | Resample to new voxel spacing in mm | +| `NII.resample_from_to(other)` | Resample to match the grid of another `NII` | +| `NII.apply_mask(mask)` | Zero-out voxels outside a binary/label mask | +| `NII.map_labels(label_map)` | Remap integer labels | +| `NII.save(path)` | Save to disk as `.nii` or `.nii.gz` | +| `NII.get_array()` | Return a copy of the underlying numpy array | +| `NII.get_seg_array()` | Same as `get_array()` but asserts `seg=True` | +| `Image_Reference` | Type alias: `BIDS_FILE | Nifti1Image | Path | str | NII` | + +### `bids_files.py` — BIDS dataset navigation + +| Symbol | Description | +|---|---| +| `BIDS_Global_info` | Scans a dataset root and indexes all BIDS files | +| `BIDS_Global_info.enumerate_subjects()` | Iterate over subjects as `(subject_id, Subject_Container)` | +| `Subject_Container` | Per-subject file index; entry point for queries | +| `Subject_Container.new_query()` | Returns a `Searchquery` for this subject | +| `BIDS_FILE` | One file parsed into BIDS entities (sub, ses, format, …) | +| `BIDS_FILE.open_nii()` | Load this file's NIfTI | +| `BIDS_FILE.get_changed_path(...)` | Derive a new path with changed BIDS entities | +| `Searchquery` | Fluent query builder: `.filter()`, `.loop_dict()`, `.first()` | +| `BIDS_Family` | `dict[str, list[BIDS_FILE]]` grouping files by format | + +### `poi.py` — Points of Interest + +| Symbol | Description | +|---|---| +| `POI` | Maps `(vertebra_id, subregion_id) → (x, y, z)` | +| `calc_centroids(seg_nii)` | Compute centroids for every label in a segmentation | +| `calc_poi_from_subreg_vert(vert, subreg)` | Compute POIs from paired vertebra + subregion segmentations | +| `POI.save(path)` | Serialise to JSON | +| `POI.load(path)` | Deserialise from JSON | +| `POI.to_global(ref)` | Convert from voxel to world (mm) coordinates | +| `POI.to_local(ref)` | Convert from world to voxel coordinates | + +### `np_utils.py` — NumPy utilities + +| Symbol | Description | +|---|---| +| `np_extract_label(arr, label)` | Extract a single label as a binary mask | +| `np_center_of_mass(arr)` | Per-label centre-of-mass | +| `np_volume(arr)` | Per-label voxel count | +| `np_bbox_binary(mask)` | Bounding-box slice tuple for a binary array | +| `np_dilate_msk(arr, mm, zoom)` | Morphological dilation by `mm` millimetres | +| `np_erode_msk(arr, mm, zoom)` | Morphological erosion | +| `np_fill_holes(arr)` | Fill holes per label | +| `np_connected_components(arr)` | Label connected components | +| `np_map_labels(arr, label_map)` | Remap label integers via a dict | +| `np_unique(arr)` | Unique values (faster than `np.unique` for uint arrays) | + +### `vert_constants.py` — Anatomical constants + +| Symbol | Description | +|---|---| +| `Location` | `IntEnum` of anatomical subregion IDs (used as POI keys) | +| `Vertebra_Instance` | Maps integer IDs → anatomical names (C1–S1) | +| `v_name2idx` | Dict: `"L1" → 20`, etc. | +| `v_idx2name` | Dict: `20 → "L1"`, etc. | +| `v_idx_order` | Canonical sort order for vertebra IDs | +| `ZOOMS` | Type alias: `tuple[float, float, float]` | +| `AX_CODES` | Type alias: `tuple[str, str, str]` | +| `AFFINE` | Type alias: `np.ndarray` (4×4) | + +## Quick Example + +```python +from TPTBox import NII, BIDS_Global_info, calc_centroids + +# Load and resample a CT +ct = NII.load("sub-001_ct.nii.gz", seg=False) +ct_ras = ct.reorient(("R", "A", "S")).rescale((1.0, 1.0, 1.0)) + +# Compute centroids from a segmentation +seg = NII.load("sub-001_seg.nii.gz", seg=True) +poi = calc_centroids(seg) +print(poi) + +# Scan a BIDS dataset +bids = BIDS_Global_info(["dataset/"], parents=["rawdata"]) +for subj, container in bids.enumerate_subjects(): + t2 = container.new_query().filter("format", "T2w").first() +``` diff --git a/TPTBox/core/bids_constants.py b/TPTBox/core/bids_constants.py index 6519282..d534de3 100755 --- a/TPTBox/core/bids_constants.py +++ b/TPTBox/core/bids_constants.py @@ -311,6 +311,7 @@ "Hemisphere": "hemi", # [L,R] "Sample": "sample", # such as tissue, primary cell or cell-free sample. # Sub recordings - Use when necessary. + "Split": "split", # Never use, legacy. Is applied inconsistently by other datasets. ## Contrast "Contrast enhancement phase": "ce", "Tracer": "trc", # use ce before this one. @@ -340,7 +341,6 @@ # Single class segmentation "Label": "label", # Others (never used) - "Split": "split", # Never use, legacy. Is applied inconsistently by other datasets. "Density": "den", "version": "version", "Description": "desc", diff --git a/TPTBox/core/bids_files.py b/TPTBox/core/bids_files.py index d4daa34..4fdd4bf 100755 --- a/TPTBox/core/bids_files.py +++ b/TPTBox/core/bids_files.py @@ -8,11 +8,15 @@ import typing from collections.abc import Sequence from pathlib import Path +from typing import TYPE_CHECKING from warnings import warn import numpy as np import TPTBox + +if TYPE_CHECKING: + from TPTBox.core.nii_poi_abstract import Grid from TPTBox.core.bids_constants import ( entities, entities_keys, @@ -45,7 +49,19 @@ # If the session level is omitted in the folder structure, the filename MUST begin with the string sub-