Enhance project structure and functionality by updating .gitignore, removing setup.py, and adding runtime configuration. Implement new features in CLI and measurement functions, and introduce tests for configuration and ring width measurements.
Browse files- .gitignore +25 -2
- README.md +26 -15
- environment.yml +1 -1
- run.sh +3 -16
- setup.py +0 -36
- tests/test_config.py +50 -0
- tests/test_ring_widths.py +99 -0
- tests/test_runtime.py +32 -0
- vascx_models/__main__.py +9 -0
- vascx_models/cli.py +18 -2
- vascx_models/ring_widths.py +429 -0
- vascx_models/runtime.py +37 -0
.gitignore
CHANGED
|
@@ -1,6 +1,29 @@
|
|
| 1 |
-
|
| 2 |
-
|
|
|
|
|
|
|
|
|
|
| 3 |
*.egg-info
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
*.zip
|
| 5 |
.DS_Store
|
| 6 |
.cache/
|
|
|
|
| 1 |
+
__pycache__/
|
| 2 |
+
*.py[cod]
|
| 3 |
+
*$py.class
|
| 4 |
+
*.so
|
| 5 |
+
*.egg
|
| 6 |
*.egg-info
|
| 7 |
+
.Python
|
| 8 |
+
.coverage
|
| 9 |
+
.coverage.*
|
| 10 |
+
.hypothesis/
|
| 11 |
+
.ipynb_checkpoints/
|
| 12 |
+
.mypy_cache/
|
| 13 |
+
.nox/
|
| 14 |
+
.pyre/
|
| 15 |
+
.pytest_cache/
|
| 16 |
+
.ruff_cache/
|
| 17 |
+
.tox/
|
| 18 |
+
.venv/
|
| 19 |
+
ENV/
|
| 20 |
+
build/
|
| 21 |
+
dist/
|
| 22 |
+
env/
|
| 23 |
+
htmlcov/
|
| 24 |
+
pip-wheel-metadata/
|
| 25 |
+
site/
|
| 26 |
+
venv/
|
| 27 |
*.zip
|
| 28 |
.DS_Store
|
| 29 |
.cache/
|
README.md
CHANGED
|
@@ -15,7 +15,7 @@ It was cloned from the upstream VascX repository on April 20, 2026, and the work
|
|
| 15 |
It now serves as a self-contained fork for running the VascX retinal fundus analysis pipeline, with:
|
| 16 |
|
| 17 |
- the VascX model weights tracked in Git LFS
|
| 18 |
-
- the Python package
|
| 19 |
- fork-specific packaging and runtime fixes
|
| 20 |
- a root `config.yaml` for controlling overlay layers and colors
|
| 21 |
|
|
@@ -25,14 +25,15 @@ This is not the canonical upstream repository. The upstream project remains `Eye
|
|
| 25 |
|
| 26 |
## What Stays Compatible
|
| 27 |
|
| 28 |
-
- The
|
| 29 |
-
- The Python package is still `vascx_models`
|
| 30 |
- The model layout and output structure are kept compatible with the upstream VascX workflow
|
| 31 |
|
| 32 |
## What Changed In This Fork
|
| 33 |
|
| 34 |
- Repository identity is now `vascx-fork`
|
| 35 |
- The default conda environment name in `environment.yml` and `run.sh` is `vascx-fork`
|
|
|
|
|
|
|
| 36 |
- Overlay generation can now be configured from the root `config.yaml`
|
| 37 |
- Local helper scripts and docs were updated to point at this fork instead of the upstream Hub repo
|
| 38 |
- Generated outputs, caches, and other non-repository artifacts are excluded from version control
|
|
@@ -52,24 +53,29 @@ conda env create -f environment.yml
|
|
| 52 |
conda activate vascx-fork
|
| 53 |
```
|
| 54 |
|
| 55 |
-
|
| 56 |
|
| 57 |
```bash
|
| 58 |
-
|
| 59 |
-
pip install -e .
|
| 60 |
```
|
| 61 |
|
| 62 |
-
If you are managing your own environment, install `torch`
|
| 63 |
|
| 64 |
## Quick Start
|
| 65 |
|
| 66 |
Run the full pipeline:
|
| 67 |
|
| 68 |
```bash
|
| 69 |
-
|
| 70 |
```
|
| 71 |
|
| 72 |
-
The
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 73 |
|
| 74 |
`DATA_PATH` can be:
|
| 75 |
|
|
@@ -79,10 +85,11 @@ The entry point is still `vascx` even though the repository is named `vascx-fork
|
|
| 79 |
Typical examples:
|
| 80 |
|
| 81 |
```bash
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
|
|
|
| 86 |
```
|
| 87 |
|
| 88 |
## Overlay Configuration
|
|
@@ -92,7 +99,7 @@ This fork adds a root-level `config.yaml` for overlay behavior.
|
|
| 92 |
If `config.yaml` exists in the current working directory or repository root, `vascx run` loads it automatically. You can also pass a specific file:
|
| 93 |
|
| 94 |
```bash
|
| 95 |
-
|
| 96 |
```
|
| 97 |
|
| 98 |
Default overlay config:
|
|
@@ -138,16 +145,20 @@ OUTPUT_PATH/
|
|
| 138 |
├── overlays/
|
| 139 |
├── bounds.csv
|
| 140 |
├── disc_geometry.csv
|
|
|
|
| 141 |
├── quality.csv
|
| 142 |
└── fovea.csv
|
| 143 |
```
|
| 144 |
|
|
|
|
|
|
|
| 145 |
## Repository Contents
|
| 146 |
|
| 147 |
- `vascx_models/`: package source and CLI
|
| 148 |
- `artery_vein/`, `disc/`, `fovea/`, `vessels/`, `quality/`, `odfd/`, `discedge/`: model artifacts
|
| 149 |
- `config.yaml`: fork-specific overlay configuration
|
| 150 |
-
- `run.sh`:
|
|
|
|
| 151 |
- `notebooks/`: preprocessing and inference examples
|
| 152 |
|
| 153 |
## Upstream Reference
|
|
|
|
| 15 |
It now serves as a self-contained fork for running the VascX retinal fundus analysis pipeline, with:
|
| 16 |
|
| 17 |
- the VascX model weights tracked in Git LFS
|
| 18 |
+
- the Python package used to run preprocessing and inference from this repo
|
| 19 |
- fork-specific packaging and runtime fixes
|
| 20 |
- a root `config.yaml` for controlling overlay layers and colors
|
| 21 |
|
|
|
|
| 25 |
|
| 26 |
## What Stays Compatible
|
| 27 |
|
| 28 |
+
- The Python package name is still `vascx_models`
|
|
|
|
| 29 |
- The model layout and output structure are kept compatible with the upstream VascX workflow
|
| 30 |
|
| 31 |
## What Changed In This Fork
|
| 32 |
|
| 33 |
- Repository identity is now `vascx-fork`
|
| 34 |
- The default conda environment name in `environment.yml` and `run.sh` is `vascx-fork`
|
| 35 |
+
- The legacy `setup.py` and installed `vascx` console script were removed
|
| 36 |
+
- Supported entrypoints are `./run.sh` and `python -m vascx_models`
|
| 37 |
- Overlay generation can now be configured from the root `config.yaml`
|
| 38 |
- Local helper scripts and docs were updated to point at this fork instead of the upstream Hub repo
|
| 39 |
- Generated outputs, caches, and other non-repository artifacts are excluded from version control
|
|
|
|
| 53 |
conda activate vascx-fork
|
| 54 |
```
|
| 55 |
|
| 56 |
+
If you update `environment.yml` later, refresh the env with:
|
| 57 |
|
| 58 |
```bash
|
| 59 |
+
conda env update -f environment.yml --prune
|
|
|
|
| 60 |
```
|
| 61 |
|
| 62 |
+
If you are managing your own environment instead of using `environment.yml`, install `torch`, `torchvision`, `retinalysis-fundusprep`, and `retinalysis-inference` before running the package.
|
| 63 |
|
| 64 |
## Quick Start
|
| 65 |
|
| 66 |
Run the full pipeline:
|
| 67 |
|
| 68 |
```bash
|
| 69 |
+
./run.sh
|
| 70 |
```
|
| 71 |
|
| 72 |
+
The standard Python entrypoint is:
|
| 73 |
+
|
| 74 |
+
```bash
|
| 75 |
+
python -m vascx_models run DATA_PATH OUTPUT_PATH
|
| 76 |
+
```
|
| 77 |
+
|
| 78 |
+
Both entrypoints auto-configure the local cache and model-release directories from the repository checkout.
|
| 79 |
|
| 80 |
`DATA_PATH` can be:
|
| 81 |
|
|
|
|
| 85 |
Typical examples:
|
| 86 |
|
| 87 |
```bash
|
| 88 |
+
./run.sh --sample-run
|
| 89 |
+
python -m vascx_models run /path/to/images /path/to/output
|
| 90 |
+
python -m vascx_models run /path/to/image_list.csv /path/to/output
|
| 91 |
+
python -m vascx_models run /path/to/preprocessed/images /path/to/output --no-preprocess
|
| 92 |
+
python -m vascx_models run /path/to/images /path/to/output --no-disc --no-quality --no-fovea --no-overlay
|
| 93 |
```
|
| 94 |
|
| 95 |
## Overlay Configuration
|
|
|
|
| 99 |
If `config.yaml` exists in the current working directory or repository root, `vascx run` loads it automatically. You can also pass a specific file:
|
| 100 |
|
| 101 |
```bash
|
| 102 |
+
python -m vascx_models run DATA_PATH OUTPUT_PATH --config /path/to/config.yaml
|
| 103 |
```
|
| 104 |
|
| 105 |
Default overlay config:
|
|
|
|
| 145 |
├── overlays/
|
| 146 |
├── bounds.csv
|
| 147 |
├── disc_geometry.csv
|
| 148 |
+
├── ring_vessel_widths.csv
|
| 149 |
├── quality.csv
|
| 150 |
└── fovea.csv
|
| 151 |
```
|
| 152 |
|
| 153 |
+
`ring_vessel_widths.csv` is written when both vessel/AV and disc outputs are available. It reports artery/vein width measurements in pixels at centerline crossings of the `2r` and `3r` disc circles.
|
| 154 |
+
|
| 155 |
## Repository Contents
|
| 156 |
|
| 157 |
- `vascx_models/`: package source and CLI
|
| 158 |
- `artery_vein/`, `disc/`, `fovea/`, `vessels/`, `quality/`, `odfd/`, `discedge/`: model artifacts
|
| 159 |
- `config.yaml`: fork-specific overlay configuration
|
| 160 |
+
- `run.sh`: primary local runner
|
| 161 |
+
- `tests/`: pytest suite
|
| 162 |
- `notebooks/`: preprocessing and inference examples
|
| 163 |
|
| 164 |
## Upstream Reference
|
environment.yml
CHANGED
|
@@ -10,10 +10,10 @@ dependencies:
|
|
| 10 |
- pillow=11.*
|
| 11 |
- click=8.*
|
| 12 |
- pyyaml=6.*
|
|
|
|
| 13 |
- pip:
|
| 14 |
- torch==2.11.0
|
| 15 |
- torchvision==0.26.0
|
| 16 |
- torchaudio==2.11.0
|
| 17 |
- retinalysis-fundusprep
|
| 18 |
- retinalysis-inference
|
| 19 |
-
- -e .
|
|
|
|
| 10 |
- pillow=11.*
|
| 11 |
- click=8.*
|
| 12 |
- pyyaml=6.*
|
| 13 |
+
- pytest
|
| 14 |
- pip:
|
| 15 |
- torch==2.11.0
|
| 16 |
- torchvision==0.26.0
|
| 17 |
- torchaudio==2.11.0
|
| 18 |
- retinalysis-fundusprep
|
| 19 |
- retinalysis-inference
|
|
|
run.sh
CHANGED
|
@@ -10,7 +10,6 @@ TIMESTAMP="$(date +"%Y%m%d_%H%M%S")"
|
|
| 10 |
DEFAULT_OUTPUT_PATH="$REPO_ROOT/output_$TIMESTAMP"
|
| 11 |
OUTPUT_PATH="${OUTPUT_PATH:-$DEFAULT_OUTPUT_PATH}"
|
| 12 |
N_JOBS="${N_JOBS:-1}"
|
| 13 |
-
MODEL_RELEASES_DIR="$REPO_ROOT/model_releases"
|
| 14 |
|
| 15 |
while [[ $# -gt 0 ]]; do
|
| 16 |
case "$1" in
|
|
@@ -31,30 +30,18 @@ if [[ ! -d "$INPUT_PATH" ]]; then
|
|
| 31 |
exit 1
|
| 32 |
fi
|
| 33 |
|
| 34 |
-
mkdir -p "$
|
| 35 |
-
|
| 36 |
-
for model_path in "$REPO_ROOT"/*/*.pt; do
|
| 37 |
-
[[ -e "$model_path" ]] || continue
|
| 38 |
-
if [[ "$model_path" == "$MODEL_RELEASES_DIR"/* ]]; then
|
| 39 |
-
continue
|
| 40 |
-
fi
|
| 41 |
-
ln -sf "$model_path" "$MODEL_RELEASES_DIR/$(basename "$model_path")"
|
| 42 |
-
done
|
| 43 |
-
|
| 44 |
-
export MPLCONFIGDIR="$REPO_ROOT/.mplconfig"
|
| 45 |
-
export XDG_CACHE_HOME="$REPO_ROOT/.cache"
|
| 46 |
-
export RTNLS_MODEL_RELEASES="$MODEL_RELEASES_DIR"
|
| 47 |
|
| 48 |
echo "Running VascX Fork"
|
| 49 |
echo " conda env: $CONDA_ENV"
|
| 50 |
echo " input path: $INPUT_PATH"
|
| 51 |
echo " output path: $OUTPUT_PATH"
|
| 52 |
echo " n_jobs: $N_JOBS"
|
| 53 |
-
echo " models dir: $RTNLS_MODEL_RELEASES"
|
| 54 |
|
| 55 |
CONDA_BASE="$(conda info --base)"
|
| 56 |
# shellcheck disable=SC1091
|
| 57 |
source "$CONDA_BASE/etc/profile.d/conda.sh"
|
| 58 |
conda activate "$CONDA_ENV"
|
| 59 |
|
| 60 |
-
|
|
|
|
|
|
| 10 |
DEFAULT_OUTPUT_PATH="$REPO_ROOT/output_$TIMESTAMP"
|
| 11 |
OUTPUT_PATH="${OUTPUT_PATH:-$DEFAULT_OUTPUT_PATH}"
|
| 12 |
N_JOBS="${N_JOBS:-1}"
|
|
|
|
| 13 |
|
| 14 |
while [[ $# -gt 0 ]]; do
|
| 15 |
case "$1" in
|
|
|
|
| 30 |
exit 1
|
| 31 |
fi
|
| 32 |
|
| 33 |
+
mkdir -p "$OUTPUT_PATH"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
|
| 35 |
echo "Running VascX Fork"
|
| 36 |
echo " conda env: $CONDA_ENV"
|
| 37 |
echo " input path: $INPUT_PATH"
|
| 38 |
echo " output path: $OUTPUT_PATH"
|
| 39 |
echo " n_jobs: $N_JOBS"
|
|
|
|
| 40 |
|
| 41 |
CONDA_BASE="$(conda info --base)"
|
| 42 |
# shellcheck disable=SC1091
|
| 43 |
source "$CONDA_BASE/etc/profile.d/conda.sh"
|
| 44 |
conda activate "$CONDA_ENV"
|
| 45 |
|
| 46 |
+
cd "$REPO_ROOT"
|
| 47 |
+
exec python -m vascx_models run "$INPUT_PATH" "$OUTPUT_PATH" --n_jobs "$N_JOBS"
|
setup.py
DELETED
|
@@ -1,36 +0,0 @@
|
|
| 1 |
-
from setuptools import find_packages, setup
|
| 2 |
-
|
| 3 |
-
with open("README.md", "r") as fh:
|
| 4 |
-
long_description = fh.read()
|
| 5 |
-
|
| 6 |
-
setup(
|
| 7 |
-
name="vascx_models",
|
| 8 |
-
# using versioneer for versioning using git tags
|
| 9 |
-
# https://github.com/python-versioneer/python-versioneer/blob/master/INSTALL.md
|
| 10 |
-
# version=versioneer.get_version(),
|
| 11 |
-
# cmdclass=versioneer.get_cmdclass(),
|
| 12 |
-
author="Jose Vargas",
|
| 13 |
-
author_email="j.vargasquiros@erasmusmc.nl",
|
| 14 |
-
description="Retinal analysis toolbox for Python",
|
| 15 |
-
long_description=long_description,
|
| 16 |
-
long_description_content_type="text/markdown",
|
| 17 |
-
packages=find_packages(),
|
| 18 |
-
include_package_data=True,
|
| 19 |
-
zip_safe=False,
|
| 20 |
-
entry_points={
|
| 21 |
-
"console_scripts": [
|
| 22 |
-
"vascx = vascx_models.cli:cli",
|
| 23 |
-
]
|
| 24 |
-
},
|
| 25 |
-
install_requires=[
|
| 26 |
-
"numpy == 2.*",
|
| 27 |
-
"pandas == 2.*",
|
| 28 |
-
"tqdm == 4.*",
|
| 29 |
-
"Pillow == 11.*",
|
| 30 |
-
"click==8.*",
|
| 31 |
-
"PyYAML == 6.*",
|
| 32 |
-
"retinalysis-fundusprep",
|
| 33 |
-
"retinalysis-inference",
|
| 34 |
-
],
|
| 35 |
-
python_requires=">=3.10, <3.13",
|
| 36 |
-
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
tests/test_config.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pathlib import Path
|
| 2 |
+
|
| 3 |
+
import pytest
|
| 4 |
+
|
| 5 |
+
from vascx_models.config import load_app_config
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def test_load_app_config_accepts_aliases_and_colours(tmp_path: Path) -> None:
|
| 9 |
+
config_path = tmp_path / "config.yaml"
|
| 10 |
+
config_path.write_text(
|
| 11 |
+
"\n".join(
|
| 12 |
+
[
|
| 13 |
+
"overlay:",
|
| 14 |
+
" enabled: false",
|
| 15 |
+
" layers:",
|
| 16 |
+
" artery: false",
|
| 17 |
+
" disc_ring_2r: false",
|
| 18 |
+
" colours:",
|
| 19 |
+
" veins: '#112233'",
|
| 20 |
+
" disc_ring_3r: [4, 5, 6]",
|
| 21 |
+
]
|
| 22 |
+
),
|
| 23 |
+
encoding="utf-8",
|
| 24 |
+
)
|
| 25 |
+
|
| 26 |
+
app_config = load_app_config(config_path)
|
| 27 |
+
|
| 28 |
+
assert app_config.source_path == config_path
|
| 29 |
+
assert app_config.overlay.enabled is False
|
| 30 |
+
assert app_config.overlay.layers.arteries is False
|
| 31 |
+
assert app_config.overlay.layers.ring_2r is False
|
| 32 |
+
assert app_config.overlay.colors.vein == (17, 34, 51)
|
| 33 |
+
assert app_config.overlay.colors.ring_3r == (4, 5, 6)
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def test_load_app_config_rejects_unknown_layer(tmp_path: Path) -> None:
|
| 37 |
+
config_path = tmp_path / "config.yaml"
|
| 38 |
+
config_path.write_text(
|
| 39 |
+
"\n".join(
|
| 40 |
+
[
|
| 41 |
+
"overlay:",
|
| 42 |
+
" layers:",
|
| 43 |
+
" unknown_layer: true",
|
| 44 |
+
]
|
| 45 |
+
),
|
| 46 |
+
encoding="utf-8",
|
| 47 |
+
)
|
| 48 |
+
|
| 49 |
+
with pytest.raises(ValueError, match="Unsupported overlay layer"):
|
| 50 |
+
load_app_config(config_path)
|
tests/test_ring_widths.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pathlib import Path
|
| 2 |
+
|
| 3 |
+
import numpy as np
|
| 4 |
+
import pandas as pd
|
| 5 |
+
from PIL import Image
|
| 6 |
+
|
| 7 |
+
from vascx_models.ring_widths import measure_vessel_widths_at_disc_rings
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def _write_mask(path: Path, array: np.ndarray) -> None:
|
| 11 |
+
Image.fromarray(array.astype(np.uint8)).save(path)
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def test_measure_vessel_widths_detects_expected_crossings(tmp_path: Path) -> None:
|
| 15 |
+
vessels_dir = tmp_path / "vessels"
|
| 16 |
+
av_dir = tmp_path / "artery_vein"
|
| 17 |
+
vessels_dir.mkdir()
|
| 18 |
+
av_dir.mkdir()
|
| 19 |
+
|
| 20 |
+
height = width = 160
|
| 21 |
+
vessel = np.zeros((height, width), dtype=np.uint8)
|
| 22 |
+
av = np.zeros((height, width), dtype=np.uint8)
|
| 23 |
+
|
| 24 |
+
x_center = 80
|
| 25 |
+
vessel[:, x_center - 3 : x_center + 4] = 1
|
| 26 |
+
av[:, x_center - 3 : x_center + 4] = 2
|
| 27 |
+
|
| 28 |
+
_write_mask(vessels_dir / "sample.png", vessel)
|
| 29 |
+
_write_mask(av_dir / "sample.png", av)
|
| 30 |
+
|
| 31 |
+
geometry_path = tmp_path / "disc_geometry.csv"
|
| 32 |
+
pd.DataFrame(
|
| 33 |
+
{
|
| 34 |
+
"x_disc_center": [80.0],
|
| 35 |
+
"y_disc_center": [80.0],
|
| 36 |
+
"disc_radius_px": [20.0],
|
| 37 |
+
"ring_2r_px": [40.0],
|
| 38 |
+
"ring_3r_px": [60.0],
|
| 39 |
+
},
|
| 40 |
+
index=["sample"],
|
| 41 |
+
).to_csv(geometry_path)
|
| 42 |
+
|
| 43 |
+
output_path = tmp_path / "ring_vessel_widths.csv"
|
| 44 |
+
df = measure_vessel_widths_at_disc_rings(
|
| 45 |
+
vessels_dir=vessels_dir,
|
| 46 |
+
av_dir=av_dir,
|
| 47 |
+
disc_geometry_path=geometry_path,
|
| 48 |
+
output_path=output_path,
|
| 49 |
+
)
|
| 50 |
+
|
| 51 |
+
assert output_path.exists()
|
| 52 |
+
assert list(df["ring"]) == ["2r", "2r", "3r", "3r"]
|
| 53 |
+
assert list(df["vessel_type"]) == ["vein", "vein", "vein", "vein"]
|
| 54 |
+
assert df["width_px"].tolist() == [7.0, 7.0, 7.0, 7.0]
|
| 55 |
+
assert df["y"].tolist() == [40.0, 120.0, 20.0, 140.0]
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def test_measure_vessel_widths_writes_empty_csv_when_no_crossings(tmp_path: Path) -> None:
|
| 59 |
+
vessels_dir = tmp_path / "vessels"
|
| 60 |
+
av_dir = tmp_path / "artery_vein"
|
| 61 |
+
vessels_dir.mkdir()
|
| 62 |
+
av_dir.mkdir()
|
| 63 |
+
|
| 64 |
+
empty = np.zeros((64, 64), dtype=np.uint8)
|
| 65 |
+
_write_mask(vessels_dir / "sample.png", empty)
|
| 66 |
+
_write_mask(av_dir / "sample.png", empty)
|
| 67 |
+
|
| 68 |
+
geometry_path = tmp_path / "disc_geometry.csv"
|
| 69 |
+
pd.DataFrame(
|
| 70 |
+
{
|
| 71 |
+
"x_disc_center": [32.0],
|
| 72 |
+
"y_disc_center": [32.0],
|
| 73 |
+
"disc_radius_px": [10.0],
|
| 74 |
+
"ring_2r_px": [20.0],
|
| 75 |
+
"ring_3r_px": [30.0],
|
| 76 |
+
},
|
| 77 |
+
index=["sample"],
|
| 78 |
+
).to_csv(geometry_path)
|
| 79 |
+
|
| 80 |
+
output_path = tmp_path / "ring_vessel_widths.csv"
|
| 81 |
+
df = measure_vessel_widths_at_disc_rings(
|
| 82 |
+
vessels_dir=vessels_dir,
|
| 83 |
+
av_dir=av_dir,
|
| 84 |
+
disc_geometry_path=geometry_path,
|
| 85 |
+
output_path=output_path,
|
| 86 |
+
)
|
| 87 |
+
|
| 88 |
+
assert output_path.exists()
|
| 89 |
+
assert df.empty
|
| 90 |
+
assert list(df.columns) == [
|
| 91 |
+
"image_id",
|
| 92 |
+
"ring",
|
| 93 |
+
"ring_radius_px",
|
| 94 |
+
"crossing_index",
|
| 95 |
+
"x",
|
| 96 |
+
"y",
|
| 97 |
+
"width_px",
|
| 98 |
+
"vessel_type",
|
| 99 |
+
]
|
tests/test_runtime.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
from pathlib import Path
|
| 3 |
+
|
| 4 |
+
from vascx_models import runtime
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
def test_configure_runtime_environment_sets_paths_and_links_models(
|
| 8 |
+
tmp_path: Path, monkeypatch
|
| 9 |
+
) -> None:
|
| 10 |
+
package_dir = tmp_path / "vascx_models"
|
| 11 |
+
package_dir.mkdir()
|
| 12 |
+
(package_dir / "__init__.py").write_text("", encoding="utf-8")
|
| 13 |
+
|
| 14 |
+
weights_dir = tmp_path / "disc"
|
| 15 |
+
weights_dir.mkdir()
|
| 16 |
+
model_path = weights_dir / "disc_test.pt"
|
| 17 |
+
model_path.write_text("model", encoding="utf-8")
|
| 18 |
+
|
| 19 |
+
monkeypatch.setattr(runtime, "repo_root", lambda: tmp_path)
|
| 20 |
+
monkeypatch.delenv("MPLCONFIGDIR", raising=False)
|
| 21 |
+
monkeypatch.delenv("XDG_CACHE_HOME", raising=False)
|
| 22 |
+
monkeypatch.delenv("RTNLS_MODEL_RELEASES", raising=False)
|
| 23 |
+
|
| 24 |
+
runtime.configure_runtime_environment()
|
| 25 |
+
|
| 26 |
+
assert Path(os.environ["MPLCONFIGDIR"]) == tmp_path / ".mplconfig"
|
| 27 |
+
assert Path(os.environ["XDG_CACHE_HOME"]) == tmp_path / ".cache"
|
| 28 |
+
assert Path(os.environ["RTNLS_MODEL_RELEASES"]) == tmp_path / "model_releases"
|
| 29 |
+
|
| 30 |
+
linked_model = tmp_path / "model_releases" / "disc_test.pt"
|
| 31 |
+
assert linked_model.is_symlink()
|
| 32 |
+
assert linked_model.resolve() == model_path.resolve()
|
vascx_models/__main__.py
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from .runtime import configure_runtime_environment
|
| 2 |
+
|
| 3 |
+
configure_runtime_environment()
|
| 4 |
+
|
| 5 |
+
from .cli import cli
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
if __name__ == "__main__":
|
| 9 |
+
cli()
|
vascx_models/cli.py
CHANGED
|
@@ -9,6 +9,10 @@ from rtnls_fundusprep.cli import _run_preprocessing
|
|
| 9 |
|
| 10 |
from .config import load_app_config
|
| 11 |
from .disc_rings import generate_disc_rings
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
from .inference import (
|
| 13 |
preferred_device,
|
| 14 |
run_fovea_detection,
|
|
@@ -16,6 +20,7 @@ from .inference import (
|
|
| 16 |
run_segmentation_disc,
|
| 17 |
run_segmentation_vessels_and_av,
|
| 18 |
)
|
|
|
|
| 19 |
from .utils import batch_create_overlays
|
| 20 |
|
| 21 |
logger = logging.getLogger(__name__)
|
|
@@ -122,6 +127,7 @@ def run(
|
|
| 122 |
quality_path = output_path / "quality.csv" if quality else None
|
| 123 |
fovea_path = output_path / "fovea.csv" if fovea else None
|
| 124 |
disc_geometry_path = output_path / "disc_geometry.csv" if disc else None
|
|
|
|
| 125 |
|
| 126 |
# Determine if input is a folder or CSV file
|
| 127 |
data_path = Path(data_path)
|
|
@@ -220,7 +226,17 @@ def run(
|
|
| 220 |
logger.info("2r disc rings saved to %s", disc_ring_2r_path)
|
| 221 |
logger.info("3r disc rings saved to %s", disc_ring_3r_path)
|
| 222 |
|
| 223 |
-
# Step 5:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 224 |
df_fovea = None
|
| 225 |
if fovea:
|
| 226 |
logger.info("Running fovea detection")
|
|
@@ -230,7 +246,7 @@ def run(
|
|
| 230 |
df_fovea.to_csv(fovea_path)
|
| 231 |
logger.info("Fovea detection results saved to %s", fovea_path)
|
| 232 |
|
| 233 |
-
# Step
|
| 234 |
if overlay_enabled:
|
| 235 |
logger.info("Creating visualization overlays")
|
| 236 |
|
|
|
|
| 9 |
|
| 10 |
from .config import load_app_config
|
| 11 |
from .disc_rings import generate_disc_rings
|
| 12 |
+
from .runtime import configure_runtime_environment
|
| 13 |
+
|
| 14 |
+
configure_runtime_environment()
|
| 15 |
+
|
| 16 |
from .inference import (
|
| 17 |
preferred_device,
|
| 18 |
run_fovea_detection,
|
|
|
|
| 20 |
run_segmentation_disc,
|
| 21 |
run_segmentation_vessels_and_av,
|
| 22 |
)
|
| 23 |
+
from .ring_widths import measure_vessel_widths_at_disc_rings
|
| 24 |
from .utils import batch_create_overlays
|
| 25 |
|
| 26 |
logger = logging.getLogger(__name__)
|
|
|
|
| 127 |
quality_path = output_path / "quality.csv" if quality else None
|
| 128 |
fovea_path = output_path / "fovea.csv" if fovea else None
|
| 129 |
disc_geometry_path = output_path / "disc_geometry.csv" if disc else None
|
| 130 |
+
ring_widths_path = output_path / "ring_vessel_widths.csv" if disc and vessels else None
|
| 131 |
|
| 132 |
# Determine if input is a folder or CSV file
|
| 133 |
data_path = Path(data_path)
|
|
|
|
| 226 |
logger.info("2r disc rings saved to %s", disc_ring_2r_path)
|
| 227 |
logger.info("3r disc rings saved to %s", disc_ring_3r_path)
|
| 228 |
|
| 229 |
+
# Step 5: Measure vessel widths at 2r and 3r if the required predictions exist
|
| 230 |
+
if disc and vessels:
|
| 231 |
+
logger.info("Measuring vessel widths at 2r and 3r disc rings")
|
| 232 |
+
measure_vessel_widths_at_disc_rings(
|
| 233 |
+
vessels_dir=vessels_path,
|
| 234 |
+
av_dir=av_path,
|
| 235 |
+
disc_geometry_path=disc_geometry_path,
|
| 236 |
+
output_path=ring_widths_path,
|
| 237 |
+
)
|
| 238 |
+
|
| 239 |
+
# Step 6: Run fovea detection if requested
|
| 240 |
df_fovea = None
|
| 241 |
if fovea:
|
| 242 |
logger.info("Running fovea detection")
|
|
|
|
| 246 |
df_fovea.to_csv(fovea_path)
|
| 247 |
logger.info("Fovea detection results saved to %s", fovea_path)
|
| 248 |
|
| 249 |
+
# Step 7: Create overlays if requested
|
| 250 |
if overlay_enabled:
|
| 251 |
logger.info("Creating visualization overlays")
|
| 252 |
|
vascx_models/ring_widths.py
ADDED
|
@@ -0,0 +1,429 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import logging
|
| 2 |
+
from pathlib import Path
|
| 3 |
+
from typing import List, Optional, Sequence, Tuple
|
| 4 |
+
|
| 5 |
+
import numpy as np
|
| 6 |
+
import pandas as pd
|
| 7 |
+
from PIL import Image
|
| 8 |
+
|
| 9 |
+
logger = logging.getLogger(__name__)
|
| 10 |
+
|
| 11 |
+
_NEIGHBORS_8: tuple[tuple[int, int], ...] = (
|
| 12 |
+
(-1, -1),
|
| 13 |
+
(-1, 0),
|
| 14 |
+
(-1, 1),
|
| 15 |
+
(0, -1),
|
| 16 |
+
(0, 1),
|
| 17 |
+
(1, -1),
|
| 18 |
+
(1, 0),
|
| 19 |
+
(1, 1),
|
| 20 |
+
)
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def _skeletonize(binary_mask: np.ndarray) -> np.ndarray:
|
| 24 |
+
"""Thin a binary mask to a one-pixel-wide skeleton with Zhang-Suen thinning."""
|
| 25 |
+
image = binary_mask.astype(np.uint8).copy()
|
| 26 |
+
if image.ndim != 2:
|
| 27 |
+
raise ValueError("Expected a 2D binary mask")
|
| 28 |
+
|
| 29 |
+
while True:
|
| 30 |
+
changed = False
|
| 31 |
+
for step in (0, 1):
|
| 32 |
+
p2 = image[:-2, 1:-1]
|
| 33 |
+
p3 = image[:-2, 2:]
|
| 34 |
+
p4 = image[1:-1, 2:]
|
| 35 |
+
p5 = image[2:, 2:]
|
| 36 |
+
p6 = image[2:, 1:-1]
|
| 37 |
+
p7 = image[2:, :-2]
|
| 38 |
+
p8 = image[1:-1, :-2]
|
| 39 |
+
p9 = image[:-2, :-2]
|
| 40 |
+
p1 = image[1:-1, 1:-1]
|
| 41 |
+
|
| 42 |
+
neighbors = p2 + p3 + p4 + p5 + p6 + p7 + p8 + p9
|
| 43 |
+
transitions = (
|
| 44 |
+
((p2 == 0) & (p3 == 1)).astype(np.uint8)
|
| 45 |
+
+ ((p3 == 0) & (p4 == 1)).astype(np.uint8)
|
| 46 |
+
+ ((p4 == 0) & (p5 == 1)).astype(np.uint8)
|
| 47 |
+
+ ((p5 == 0) & (p6 == 1)).astype(np.uint8)
|
| 48 |
+
+ ((p6 == 0) & (p7 == 1)).astype(np.uint8)
|
| 49 |
+
+ ((p7 == 0) & (p8 == 1)).astype(np.uint8)
|
| 50 |
+
+ ((p8 == 0) & (p9 == 1)).astype(np.uint8)
|
| 51 |
+
+ ((p9 == 0) & (p2 == 1)).astype(np.uint8)
|
| 52 |
+
)
|
| 53 |
+
|
| 54 |
+
if step == 0:
|
| 55 |
+
guard_1 = p2 * p4 * p6 == 0
|
| 56 |
+
guard_2 = p4 * p6 * p8 == 0
|
| 57 |
+
else:
|
| 58 |
+
guard_1 = p2 * p4 * p8 == 0
|
| 59 |
+
guard_2 = p2 * p6 * p8 == 0
|
| 60 |
+
|
| 61 |
+
remove = (
|
| 62 |
+
(p1 == 1)
|
| 63 |
+
& (neighbors >= 2)
|
| 64 |
+
& (neighbors <= 6)
|
| 65 |
+
& (transitions == 1)
|
| 66 |
+
& guard_1
|
| 67 |
+
& guard_2
|
| 68 |
+
)
|
| 69 |
+
|
| 70 |
+
if np.any(remove):
|
| 71 |
+
image[1:-1, 1:-1][remove] = 0
|
| 72 |
+
changed = True
|
| 73 |
+
|
| 74 |
+
if not changed:
|
| 75 |
+
break
|
| 76 |
+
|
| 77 |
+
return image.astype(bool)
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def _connected_components(mask: np.ndarray) -> List[np.ndarray]:
|
| 81 |
+
visited = np.zeros_like(mask, dtype=bool)
|
| 82 |
+
components: List[np.ndarray] = []
|
| 83 |
+
|
| 84 |
+
ys, xs = np.nonzero(mask)
|
| 85 |
+
for seed_y, seed_x in zip(ys, xs):
|
| 86 |
+
if visited[seed_y, seed_x]:
|
| 87 |
+
continue
|
| 88 |
+
|
| 89 |
+
stack = [(int(seed_y), int(seed_x))]
|
| 90 |
+
visited[seed_y, seed_x] = True
|
| 91 |
+
coords: List[Tuple[int, int]] = []
|
| 92 |
+
|
| 93 |
+
while stack:
|
| 94 |
+
y, x = stack.pop()
|
| 95 |
+
coords.append((y, x))
|
| 96 |
+
for dy, dx in _NEIGHBORS_8:
|
| 97 |
+
ny = y + dy
|
| 98 |
+
nx = x + dx
|
| 99 |
+
if ny < 0 or nx < 0 or ny >= mask.shape[0] or nx >= mask.shape[1]:
|
| 100 |
+
continue
|
| 101 |
+
if not mask[ny, nx] or visited[ny, nx]:
|
| 102 |
+
continue
|
| 103 |
+
visited[ny, nx] = True
|
| 104 |
+
stack.append((ny, nx))
|
| 105 |
+
|
| 106 |
+
components.append(np.asarray(coords, dtype=np.int32))
|
| 107 |
+
|
| 108 |
+
return components
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
def _local_skeleton_points(
|
| 112 |
+
skeleton: np.ndarray,
|
| 113 |
+
point_xy: np.ndarray,
|
| 114 |
+
radius_px: float,
|
| 115 |
+
) -> np.ndarray:
|
| 116 |
+
x, y = point_xy
|
| 117 |
+
height, width = skeleton.shape
|
| 118 |
+
x_min = max(0, int(np.floor(x - radius_px)))
|
| 119 |
+
x_max = min(width - 1, int(np.ceil(x + radius_px)))
|
| 120 |
+
y_min = max(0, int(np.floor(y - radius_px)))
|
| 121 |
+
y_max = min(height - 1, int(np.ceil(y + radius_px)))
|
| 122 |
+
|
| 123 |
+
window = skeleton[y_min : y_max + 1, x_min : x_max + 1]
|
| 124 |
+
ys, xs = np.nonzero(window)
|
| 125 |
+
if len(xs) == 0:
|
| 126 |
+
return np.empty((0, 2), dtype=float)
|
| 127 |
+
|
| 128 |
+
xs = xs.astype(float) + float(x_min)
|
| 129 |
+
ys = ys.astype(float) + float(y_min)
|
| 130 |
+
deltas = np.column_stack((xs - x, ys - y))
|
| 131 |
+
keep = np.sum(deltas * deltas, axis=1) <= radius_px * radius_px
|
| 132 |
+
return np.column_stack((xs[keep], ys[keep]))
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
def _estimate_tangent(points_xy: np.ndarray) -> Optional[np.ndarray]:
|
| 136 |
+
if len(points_xy) < 2:
|
| 137 |
+
return None
|
| 138 |
+
|
| 139 |
+
centered = points_xy - points_xy.mean(axis=0, keepdims=True)
|
| 140 |
+
covariance = centered.T @ centered
|
| 141 |
+
eigenvalues, eigenvectors = np.linalg.eigh(covariance)
|
| 142 |
+
tangent = eigenvectors[:, int(np.argmax(eigenvalues))]
|
| 143 |
+
norm = float(np.linalg.norm(tangent))
|
| 144 |
+
if norm == 0.0:
|
| 145 |
+
return None
|
| 146 |
+
return tangent / norm
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
def _sample_mask(mask: np.ndarray, point_xy: np.ndarray) -> float:
|
| 150 |
+
x, y = point_xy
|
| 151 |
+
height, width = mask.shape
|
| 152 |
+
if x < 0 or y < 0 or x > width - 1 or y > height - 1:
|
| 153 |
+
return 0.0
|
| 154 |
+
|
| 155 |
+
x0 = int(np.floor(x))
|
| 156 |
+
y0 = int(np.floor(y))
|
| 157 |
+
x1 = min(x0 + 1, width - 1)
|
| 158 |
+
y1 = min(y0 + 1, height - 1)
|
| 159 |
+
|
| 160 |
+
dx = x - x0
|
| 161 |
+
dy = y - y0
|
| 162 |
+
|
| 163 |
+
v00 = float(mask[y0, x0])
|
| 164 |
+
v10 = float(mask[y0, x1])
|
| 165 |
+
v01 = float(mask[y1, x0])
|
| 166 |
+
v11 = float(mask[y1, x1])
|
| 167 |
+
|
| 168 |
+
top = v00 * (1.0 - dx) + v10 * dx
|
| 169 |
+
bottom = v01 * (1.0 - dx) + v11 * dx
|
| 170 |
+
return top * (1.0 - dy) + bottom * dy
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
def _trace_boundary_distance(
|
| 174 |
+
vessel_mask: np.ndarray,
|
| 175 |
+
point_xy: np.ndarray,
|
| 176 |
+
direction_xy: np.ndarray,
|
| 177 |
+
step_px: float,
|
| 178 |
+
) -> float:
|
| 179 |
+
max_distance = float(np.hypot(*vessel_mask.shape)) + 2.0
|
| 180 |
+
current_value = _sample_mask(vessel_mask, point_xy)
|
| 181 |
+
if current_value < 0.5:
|
| 182 |
+
return float("nan")
|
| 183 |
+
|
| 184 |
+
t_inside = 0.0
|
| 185 |
+
steps = int(np.ceil(max_distance / step_px))
|
| 186 |
+
for step in range(1, steps + 1):
|
| 187 |
+
t_outside = step * step_px
|
| 188 |
+
sample_point = point_xy + direction_xy * t_outside
|
| 189 |
+
if _sample_mask(vessel_mask, sample_point) < 0.5:
|
| 190 |
+
low = t_inside
|
| 191 |
+
high = t_outside
|
| 192 |
+
for _ in range(12):
|
| 193 |
+
mid = (low + high) / 2.0
|
| 194 |
+
mid_point = point_xy + direction_xy * mid
|
| 195 |
+
if _sample_mask(vessel_mask, mid_point) >= 0.5:
|
| 196 |
+
low = mid
|
| 197 |
+
else:
|
| 198 |
+
high = mid
|
| 199 |
+
return low
|
| 200 |
+
t_inside = t_outside
|
| 201 |
+
|
| 202 |
+
return float("nan")
|
| 203 |
+
|
| 204 |
+
|
| 205 |
+
def _measure_width_along_normal(
|
| 206 |
+
vessel_mask: np.ndarray,
|
| 207 |
+
center_xy: np.ndarray,
|
| 208 |
+
tangent_xy: np.ndarray,
|
| 209 |
+
step_px: float,
|
| 210 |
+
) -> float:
|
| 211 |
+
normal_xy = np.array([-tangent_xy[1], tangent_xy[0]], dtype=float)
|
| 212 |
+
norm = float(np.linalg.norm(normal_xy))
|
| 213 |
+
if norm == 0.0:
|
| 214 |
+
return float("nan")
|
| 215 |
+
normal_xy /= norm
|
| 216 |
+
|
| 217 |
+
positive = _trace_boundary_distance(vessel_mask, center_xy, normal_xy, step_px)
|
| 218 |
+
negative = _trace_boundary_distance(vessel_mask, center_xy, -normal_xy, step_px)
|
| 219 |
+
if np.isnan(positive) or np.isnan(negative):
|
| 220 |
+
return float("nan")
|
| 221 |
+
return positive + negative
|
| 222 |
+
|
| 223 |
+
|
| 224 |
+
def _majority_vessel_type(
|
| 225 |
+
av_mask: np.ndarray,
|
| 226 |
+
skeleton: np.ndarray,
|
| 227 |
+
point_xy: np.ndarray,
|
| 228 |
+
radius_px: float,
|
| 229 |
+
) -> str:
|
| 230 |
+
local_points = _local_skeleton_points(skeleton, point_xy, radius_px)
|
| 231 |
+
if len(local_points) == 0:
|
| 232 |
+
return "ambiguous"
|
| 233 |
+
|
| 234 |
+
xs = np.rint(local_points[:, 0]).astype(int)
|
| 235 |
+
ys = np.rint(local_points[:, 1]).astype(int)
|
| 236 |
+
labels = av_mask[ys, xs]
|
| 237 |
+
|
| 238 |
+
artery_votes = int(np.sum(labels == 1))
|
| 239 |
+
vein_votes = int(np.sum(labels == 2))
|
| 240 |
+
if artery_votes > vein_votes:
|
| 241 |
+
return "artery"
|
| 242 |
+
if vein_votes > artery_votes:
|
| 243 |
+
return "vein"
|
| 244 |
+
|
| 245 |
+
x, y = point_xy
|
| 246 |
+
height, width = av_mask.shape
|
| 247 |
+
x_min = max(0, int(np.floor(x - radius_px)))
|
| 248 |
+
x_max = min(width - 1, int(np.ceil(x + radius_px)))
|
| 249 |
+
y_min = max(0, int(np.floor(y - radius_px)))
|
| 250 |
+
y_max = min(height - 1, int(np.ceil(y + radius_px)))
|
| 251 |
+
patch = av_mask[y_min : y_max + 1, x_min : x_max + 1]
|
| 252 |
+
artery_votes = int(np.sum(patch == 1))
|
| 253 |
+
vein_votes = int(np.sum(patch == 2))
|
| 254 |
+
if artery_votes > vein_votes:
|
| 255 |
+
return "artery"
|
| 256 |
+
if vein_votes > artery_votes:
|
| 257 |
+
return "vein"
|
| 258 |
+
return "ambiguous"
|
| 259 |
+
|
| 260 |
+
|
| 261 |
+
def _is_radius_crossing(
|
| 262 |
+
skeleton: np.ndarray,
|
| 263 |
+
distances: np.ndarray,
|
| 264 |
+
center_xy: np.ndarray,
|
| 265 |
+
target_radius_px: float,
|
| 266 |
+
search_radius_px: float,
|
| 267 |
+
) -> bool:
|
| 268 |
+
local_points = _local_skeleton_points(skeleton, center_xy, search_radius_px)
|
| 269 |
+
if len(local_points) == 0:
|
| 270 |
+
return False
|
| 271 |
+
|
| 272 |
+
xs = np.rint(local_points[:, 0]).astype(int)
|
| 273 |
+
ys = np.rint(local_points[:, 1]).astype(int)
|
| 274 |
+
local_distances = distances[ys, xs]
|
| 275 |
+
return (
|
| 276 |
+
float(np.min(local_distances)) <= target_radius_px - 0.75
|
| 277 |
+
and float(np.max(local_distances)) >= target_radius_px + 0.75
|
| 278 |
+
)
|
| 279 |
+
|
| 280 |
+
|
| 281 |
+
def _ring_records_for_image(
|
| 282 |
+
image_id: str,
|
| 283 |
+
vessel_mask: np.ndarray,
|
| 284 |
+
av_mask: np.ndarray,
|
| 285 |
+
disc_center_xy: np.ndarray,
|
| 286 |
+
disc_radius_px: float,
|
| 287 |
+
ring_multipliers: Sequence[float],
|
| 288 |
+
crossing_band_width_px: float,
|
| 289 |
+
tangent_window_px: float,
|
| 290 |
+
label_window_px: float,
|
| 291 |
+
measurement_step_px: float,
|
| 292 |
+
) -> List[dict]:
|
| 293 |
+
if not np.any(vessel_mask):
|
| 294 |
+
return []
|
| 295 |
+
|
| 296 |
+
skeleton = _skeletonize(vessel_mask)
|
| 297 |
+
if not np.any(skeleton):
|
| 298 |
+
return []
|
| 299 |
+
|
| 300 |
+
yy, xx = np.indices(vessel_mask.shape, dtype=float)
|
| 301 |
+
distances = np.hypot(xx - disc_center_xy[0], yy - disc_center_xy[1])
|
| 302 |
+
|
| 303 |
+
records: List[dict] = []
|
| 304 |
+
for ring_multiplier in ring_multipliers:
|
| 305 |
+
target_radius_px = float(disc_radius_px * ring_multiplier)
|
| 306 |
+
band_mask = skeleton & (np.abs(distances - target_radius_px) <= crossing_band_width_px)
|
| 307 |
+
components = _connected_components(band_mask)
|
| 308 |
+
crossing_index = 0
|
| 309 |
+
|
| 310 |
+
for component in components:
|
| 311 |
+
component_distances = distances[component[:, 0], component[:, 1]]
|
| 312 |
+
best_idx = int(np.argmin(np.abs(component_distances - target_radius_px)))
|
| 313 |
+
y_px = int(component[best_idx, 0])
|
| 314 |
+
x_px = int(component[best_idx, 1])
|
| 315 |
+
center_xy = np.array([float(x_px), float(y_px)], dtype=float)
|
| 316 |
+
|
| 317 |
+
local_points = _local_skeleton_points(skeleton, center_xy, tangent_window_px)
|
| 318 |
+
tangent_xy = _estimate_tangent(local_points)
|
| 319 |
+
if tangent_xy is None:
|
| 320 |
+
continue
|
| 321 |
+
|
| 322 |
+
if not _is_radius_crossing(
|
| 323 |
+
skeleton=skeleton,
|
| 324 |
+
distances=distances,
|
| 325 |
+
center_xy=center_xy,
|
| 326 |
+
target_radius_px=target_radius_px,
|
| 327 |
+
search_radius_px=tangent_window_px,
|
| 328 |
+
):
|
| 329 |
+
continue
|
| 330 |
+
|
| 331 |
+
width_px = _measure_width_along_normal(
|
| 332 |
+
vessel_mask=vessel_mask,
|
| 333 |
+
center_xy=center_xy,
|
| 334 |
+
tangent_xy=tangent_xy,
|
| 335 |
+
step_px=measurement_step_px,
|
| 336 |
+
)
|
| 337 |
+
if np.isnan(width_px):
|
| 338 |
+
continue
|
| 339 |
+
|
| 340 |
+
crossing_index += 1
|
| 341 |
+
records.append(
|
| 342 |
+
{
|
| 343 |
+
"image_id": image_id,
|
| 344 |
+
"ring": f"{int(ring_multiplier)}r",
|
| 345 |
+
"ring_radius_px": target_radius_px,
|
| 346 |
+
"crossing_index": crossing_index,
|
| 347 |
+
"x": center_xy[0],
|
| 348 |
+
"y": center_xy[1],
|
| 349 |
+
"width_px": float(width_px),
|
| 350 |
+
"vessel_type": _majority_vessel_type(
|
| 351 |
+
av_mask=av_mask,
|
| 352 |
+
skeleton=skeleton,
|
| 353 |
+
point_xy=center_xy,
|
| 354 |
+
radius_px=label_window_px,
|
| 355 |
+
),
|
| 356 |
+
}
|
| 357 |
+
)
|
| 358 |
+
|
| 359 |
+
return records
|
| 360 |
+
|
| 361 |
+
|
| 362 |
+
def measure_vessel_widths_at_disc_rings(
|
| 363 |
+
vessels_dir: Path,
|
| 364 |
+
av_dir: Path,
|
| 365 |
+
disc_geometry_path: Path,
|
| 366 |
+
output_path: Optional[Path] = None,
|
| 367 |
+
ring_multipliers: Sequence[float] = (2.0, 3.0),
|
| 368 |
+
crossing_band_width_px: float = 1.5,
|
| 369 |
+
tangent_window_px: float = 10.0,
|
| 370 |
+
label_window_px: float = 7.0,
|
| 371 |
+
measurement_step_px: float = 0.25,
|
| 372 |
+
) -> pd.DataFrame:
|
| 373 |
+
"""Measure vessel widths where centerlines cross the 2r and 3r disc circles."""
|
| 374 |
+
if not disc_geometry_path.exists():
|
| 375 |
+
raise FileNotFoundError(f"Disc geometry file not found: {disc_geometry_path}")
|
| 376 |
+
if not vessels_dir.exists():
|
| 377 |
+
raise FileNotFoundError(f"Vessels directory not found: {vessels_dir}")
|
| 378 |
+
if not av_dir.exists():
|
| 379 |
+
raise FileNotFoundError(f"AV directory not found: {av_dir}")
|
| 380 |
+
|
| 381 |
+
df_geometry = pd.read_csv(disc_geometry_path, index_col=0)
|
| 382 |
+
records: List[dict] = []
|
| 383 |
+
|
| 384 |
+
for image_id, row in df_geometry.iterrows():
|
| 385 |
+
if pd.isna(row["x_disc_center"]) or pd.isna(row["y_disc_center"]) or pd.isna(
|
| 386 |
+
row["disc_radius_px"]
|
| 387 |
+
):
|
| 388 |
+
logger.warning("Skipping %s because disc geometry is missing", image_id)
|
| 389 |
+
continue
|
| 390 |
+
|
| 391 |
+
vessel_path = vessels_dir / f"{image_id}.png"
|
| 392 |
+
av_path = av_dir / f"{image_id}.png"
|
| 393 |
+
if not vessel_path.exists() or not av_path.exists():
|
| 394 |
+
logger.warning("Skipping %s because vessel or AV mask is missing", image_id)
|
| 395 |
+
continue
|
| 396 |
+
|
| 397 |
+
vessel_mask = np.array(Image.open(vessel_path)) > 0
|
| 398 |
+
av_mask = np.array(Image.open(av_path))
|
| 399 |
+
image_records = _ring_records_for_image(
|
| 400 |
+
image_id=image_id,
|
| 401 |
+
vessel_mask=vessel_mask,
|
| 402 |
+
av_mask=av_mask,
|
| 403 |
+
disc_center_xy=np.array([row["x_disc_center"], row["y_disc_center"]], dtype=float),
|
| 404 |
+
disc_radius_px=float(row["disc_radius_px"]),
|
| 405 |
+
ring_multipliers=ring_multipliers,
|
| 406 |
+
crossing_band_width_px=crossing_band_width_px,
|
| 407 |
+
tangent_window_px=tangent_window_px,
|
| 408 |
+
label_window_px=label_window_px,
|
| 409 |
+
measurement_step_px=measurement_step_px,
|
| 410 |
+
)
|
| 411 |
+
records.extend(image_records)
|
| 412 |
+
|
| 413 |
+
df_widths = pd.DataFrame.from_records(
|
| 414 |
+
records,
|
| 415 |
+
columns=[
|
| 416 |
+
"image_id",
|
| 417 |
+
"ring",
|
| 418 |
+
"ring_radius_px",
|
| 419 |
+
"crossing_index",
|
| 420 |
+
"x",
|
| 421 |
+
"y",
|
| 422 |
+
"width_px",
|
| 423 |
+
"vessel_type",
|
| 424 |
+
],
|
| 425 |
+
)
|
| 426 |
+
if output_path is not None:
|
| 427 |
+
df_widths.to_csv(output_path, index=False)
|
| 428 |
+
logger.info("Ring vessel width measurements saved to %s", output_path)
|
| 429 |
+
return df_widths
|
vascx_models/runtime.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import os
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
def repo_root() -> Path:
|
| 8 |
+
return Path(__file__).resolve().parent.parent
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def configure_runtime_environment() -> None:
|
| 12 |
+
root = repo_root()
|
| 13 |
+
|
| 14 |
+
mplconfigdir = Path(os.environ.setdefault("MPLCONFIGDIR", str(root / ".mplconfig")))
|
| 15 |
+
cache_dir = Path(os.environ.setdefault("XDG_CACHE_HOME", str(root / ".cache")))
|
| 16 |
+
model_releases_dir = Path(
|
| 17 |
+
os.environ.setdefault("RTNLS_MODEL_RELEASES", str(root / "model_releases"))
|
| 18 |
+
)
|
| 19 |
+
|
| 20 |
+
mplconfigdir.mkdir(exist_ok=True, parents=True)
|
| 21 |
+
cache_dir.mkdir(exist_ok=True, parents=True)
|
| 22 |
+
model_releases_dir.mkdir(exist_ok=True, parents=True)
|
| 23 |
+
|
| 24 |
+
for model_path in root.glob("*/*.pt"):
|
| 25 |
+
if model_releases_dir in model_path.parents:
|
| 26 |
+
continue
|
| 27 |
+
|
| 28 |
+
symlink_path = model_releases_dir / model_path.name
|
| 29 |
+
if symlink_path.exists() or symlink_path.is_symlink():
|
| 30 |
+
try:
|
| 31 |
+
if symlink_path.resolve() == model_path.resolve():
|
| 32 |
+
continue
|
| 33 |
+
except FileNotFoundError:
|
| 34 |
+
pass
|
| 35 |
+
symlink_path.unlink()
|
| 36 |
+
|
| 37 |
+
symlink_path.symlink_to(model_path.resolve())
|