zyf0717 commited on
Commit
5b06e4e
·
1 Parent(s): e1e8b6a

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 CHANGED
@@ -1,6 +1,29 @@
1
- *.pyc
2
- __pycache__
 
 
 
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 and CLI used to run preprocessing and inference
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 installed CLI command is still `vascx`
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
- 3. Install the runtime dependencies if needed:
56
 
57
  ```bash
58
- pip install retinalysis-fundusprep retinalysis-inference
59
- pip install -e .
60
  ```
61
 
62
- If you are managing your own environment, install `torch` and `torchvision` for your platform before running the package.
63
 
64
  ## Quick Start
65
 
66
  Run the full pipeline:
67
 
68
  ```bash
69
- vascx run DATA_PATH OUTPUT_PATH
70
  ```
71
 
72
- The entry point is still `vascx` even though the repository is named `vascx-fork`.
 
 
 
 
 
 
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
- vascx run /path/to/images /path/to/output
83
- vascx run /path/to/image_list.csv /path/to/output
84
- vascx run /path/to/preprocessed/images /path/to/output --no-preprocess
85
- vascx run /path/to/images /path/to/output --no-disc --no-quality --no-fovea --no-overlay
 
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
- vascx run DATA_PATH OUTPUT_PATH --config /path/to/config.yaml
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`: convenience runner for local execution
 
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 "$REPO_ROOT/.mplconfig" "$REPO_ROOT/.cache" "$MODEL_RELEASES_DIR" "$OUTPUT_PATH"
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
- exec python -c "from vascx_models.cli import cli; cli()" run "$INPUT_PATH" "$OUTPUT_PATH" --n_jobs "$N_JOBS"
 
 
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: Run fovea detection if requested
 
 
 
 
 
 
 
 
 
 
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 6: Create overlays if requested
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())