diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000..5ace4600a --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,6 @@ +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" diff --git a/.github/workflows/code-style.yml b/.github/workflows/code-style.yml index b31dac38f..247df6bce 100644 --- a/.github/workflows/code-style.yml +++ b/.github/workflows/code-style.yml @@ -14,11 +14,11 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: path: 'src' - name: Install uv - uses: astral-sh/setup-uv@v5 + uses: astral-sh/setup-uv@v7 with: python-version: '3.10' - name: Run Ruff diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 0c7dcd772..c6d81b45b 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -20,11 +20,11 @@ jobs: - name: Get repository name. run: echo "FASTSURFER_DIR=$GITHUB_WORKSPACE" >> $GITHUB_ENV - name: Checkout repository - uses: actions/checkout@v6.0.2 + uses: actions/checkout@v6 with: fetch-depth: 0 - name: Set up python environment - uses: actions/setup-python@v6.2.0 + uses: actions/setup-python@v6 with: python-version: '3.10' - name: install dependencies diff --git a/.github/workflows/doc.yml b/.github/workflows/doc.yml index d62dbb8f1..ae6664c7d 100644 --- a/.github/workflows/doc.yml +++ b/.github/workflows/doc.yml @@ -19,11 +19,11 @@ jobs: shell: bash steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: path: src - name: Install uv - uses: astral-sh/setup-uv@v5 + uses: astral-sh/setup-uv@v7 with: python-version: '3.12' - name: Build doc @@ -33,7 +33,7 @@ jobs: run: | uv run --project src --extra doc sphinx-build -WT --keep-going -j auto src/doc doc-build - name: Upload documentation - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: doc path: | diff --git a/.github/workflows/quicktest.yaml b/.github/workflows/quicktest.yaml index 38b3d4637..441646a23 100644 --- a/.github/workflows/quicktest.yaml +++ b/.github/workflows/quicktest.yaml @@ -132,15 +132,15 @@ jobs: - name: Checkout repository # DOCKER_IMAGE is build-cached => we want to build, check this repository out to build the docker image if: steps.parse.outputs.CONTINUE == 'true' && steps.parse.outputs.DOCKER_IMAGE == 'build-cached' - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Checkout repository # DOCKER_IMAGE starts with pr/*** => we want to build, check the PR out to build the docker image if: steps.parse.outputs.CONTINUE == 'true' && startsWith(steps.parse.outputs.DOCKER_IMAGE, 'pr/') - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: ref: ${{ steps.parse.outputs.DOCKER_IMAGE }} - name: Setup uv and Python 3.11 - uses: astral-sh/setup-uv@v5 + uses: astral-sh/setup-uv@v7 with: python-version: '3.11' # python 3.11 is needed to read pyproject.toml (tomllib) @@ -195,7 +195,7 @@ jobs: extra-args: "--3T" steps: - name: Check out the repository that is to be tested - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Run FastSurfer for ${{ matrix.subject-id }} with the previously created docker container uses: Deep-MI/FastSurfer/.github/actions/run-fastsurfer@dev with: @@ -221,7 +221,7 @@ jobs: if: always() && needs.build-docker.outputs.continue == 'true' steps: - name: Retrieve test JUnit files - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8 with: pattern: fastsurfer-${{ github.sha }}-junit-* merge-multiple: 'true' diff --git a/.github/workflows/unittest.yaml b/.github/workflows/unittest.yaml index 68ab6290c..9c917a9dd 100644 --- a/.github/workflows/unittest.yaml +++ b/.github/workflows/unittest.yaml @@ -39,9 +39,9 @@ jobs: pytest-flags: [""] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Setup Python 3.10 - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: '3.10' architecture: 'x64' @@ -71,7 +71,7 @@ jobs: python -m pytest "${flags[@]}" ${{ matrix.pytest-flags }} test/${{ matrix.tests }} echo "::endgroup::" - name: Upload the JUnit XML file as an artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: fastsurfer-${{ github.sha }}-${{ matrix.tests }}-junit path: /tmp/fastsurfer-unittest-${{ matrix.tests }}.junit.xml diff --git a/CorpusCallosum/shape/contour.py b/CorpusCallosum/shape/contour.py index f5a1583f9..0f23e9f15 100644 --- a/CorpusCallosum/shape/contour.py +++ b/CorpusCallosum/shape/contour.py @@ -320,6 +320,11 @@ def fill_thickness_values(self) -> None: closest_indices = known_idx[np.argsort(distances)[:2]] closest_distances = np.sort(distances)[:2] + zero_distance = closest_distances <= 1e-10 + if np.any(zero_distance): + thickness[j] = np.mean(thickness[closest_indices[zero_distance]]) + continue + # Calculate weights based on inverse distance weights = 1.0 / closest_distances weights = weights / np.sum(weights) diff --git a/CorpusCallosum/shape/thickness.py b/CorpusCallosum/shape/thickness.py index 8dc16ca47..e86eb74e3 100644 --- a/CorpusCallosum/shape/thickness.py +++ b/CorpusCallosum/shape/thickness.py @@ -113,7 +113,7 @@ def insert_point_with_thickness( point: np.ndarray, thickness_value: float, return_index: Literal[True], -) -> tuple[np.ndarray, np.ndarray, int] | list[np.ndarray, np.ndarray]: +) -> tuple[np.ndarray, np.ndarray, int]: ... @@ -148,6 +148,49 @@ def insert_point_with_thickness( insertion_index : int The index, where the point was inserted (only if return_index is True). """ + existing_distances = np.linalg.norm(contour_in_as_space[:, :2] - point[:2], axis=1) + existing_matches = np.where(existing_distances <= 1e-10)[0] + if len(existing_matches) > 0: + point_idx = int(existing_matches[0]) + if np.isnan(contour_thickness[point_idx]): + contour_thickness[point_idx] = thickness_value + + # Keep the contour cardinality stable across slices, but avoid inserting + # an exact duplicate vertex. Exact duplicates create zero-length edges in + # visualization/mesh code, while skipping the point breaks the equal + # point-count invariant expected by CCMesh.from_contours(). + before_idx = (point_idx - 1) % len(contour_in_as_space) + after_idx = (point_idx + 1) % len(contour_in_as_space) + before_vector = contour_in_as_space[before_idx, :2] - contour_in_as_space[point_idx, :2] + after_vector = contour_in_as_space[after_idx, :2] - contour_in_as_space[point_idx, :2] + before_length = np.linalg.norm(before_vector) + after_length = np.linalg.norm(after_vector) + + if after_length >= before_length and after_length > 0: + insert_idx = point_idx + 1 + direction = after_vector / after_length + edge_length = after_length + elif before_length > 0: + insert_idx = point_idx + direction = before_vector / before_length + edge_length = before_length + else: + insert_idx = point_idx + 1 + direction = np.zeros_like(point[:2]) + edge_length = 0.0 + + if edge_length > 0: + step = min(max(edge_length * 1e-6, 1e-8), edge_length * 0.5) + point = contour_in_as_space[point_idx, :2] + direction * step + + contour_in_as_space = np.insert(contour_in_as_space, insert_idx, point, axis=0) + contour_thickness = np.insert(contour_thickness, insert_idx, thickness_value) + + if return_index: + return contour_in_as_space, contour_thickness, insert_idx + else: + return contour_in_as_space, contour_thickness + # Find closest edge for the point edge_idx = find_closest_edge(point, contour_in_as_space) diff --git a/pyproject.toml b/pyproject.toml index e8bc13b65..366578507 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = 'setuptools.build_meta' [project] name = 'fastsurfer' -version = '2.5.2' +version = '2.5.3' description = 'A fast and accurate deep-learning based neuroimaging pipeline' readme = 'README.md' license = {file = 'LICENSE'} @@ -38,7 +38,7 @@ dependencies = [ 'matplotlib>=3.7.1', 'meshpy>=2025.1.1', # needed for FastSurfer-CC 'monai>=1.4.0', # needed for FastSurfer-CC - 'neuroreg>=0.6.1', # needed for registration / etiv + 'neuroreg>=0.6.2', # needed for registration / etiv 'nibabel>=5.4.0', # needed to fix a bug in nibabel 'numpy>=1.25', 'packaging', diff --git a/recon_surf/recon-surf.sh b/recon_surf/recon-surf.sh index e0e480493..8bfca5a82 100755 --- a/recon_surf/recon-surf.sh +++ b/recon_surf/recon-surf.sh @@ -714,7 +714,7 @@ for hemi in lh rh ; do # Check if the surfaceRAS was correctly set and exit otherwise (sanity check in case nibabel changes their default header behaviour) { - cmd="mris_info $outmesh | tr -s ' ' | grep -q 'vertex locs : surfaceRAS'" + cmd="mris_info $outmesh | awk '\$1 == \"vertex\" && \$2 == \"locs\" && \$3 == \":\" && \$4 == \"surfaceRAS\" { found = 1 } END { exit !found }'" echo "echo \"$cmd\"" echo "$timecmd $cmd" } | tee -a "$CMDF" diff --git a/recon_surf/smooth_aparc.py b/recon_surf/smooth_aparc.py index b1f1437e1..65e99735c 100644 --- a/recon_surf/smooth_aparc.py +++ b/recon_surf/smooth_aparc.py @@ -177,7 +177,7 @@ def mode_filter( # for num rings exponentiate adjM and add adjM from step before # we currently do this outside of mode_filter # new labels will be the same as old almost everywhere - labels_new = labels + labels_new = labels.copy() # find vertices to fill # if fillonlylabels empty, fill all if not fillonlylabel: @@ -204,8 +204,6 @@ def mode_filter( # create sparse matrix with labels at neighbors nlabels = sparse.csr_matrix((labels[JJ], (II, JJ))) # print("nlabels: {}".format(nlabels)) - from scipy.stats import mode - if not isinstance(nlabels, sparse.csr_matrix): raise ValueError("Matrix must be CSR format.") # novote = [-1,0,fillonlylabel] @@ -227,19 +225,16 @@ def mode_filter( rr = np.isin(nlabels.data, novote) nlabels.data[rr] = 0 nlabels.eliminate_zeros() - # run over all rows and compute mode (maybe vectorize later) + # Run over all rows and compute mode. The labels are non-negative at + # this point; bincount().argmax() matches scipy.stats.mode's smallest-value + # tie behavior without the heavy per-row SciPy dispatch. rempty = 0 for row in rows: rvals = nlabels.data[nlabels.indptr[row] : nlabels.indptr[row + 1]] if rvals.size == 0: rempty += 1 continue - # print(str(rvals)) - mvals = mode(rvals, keepdims=True)[0] - # print(str(mvals)) - if mvals.size != 0: - # print(str(row)+' '+str(ids[row])+' '+str(mvals[0])) - labels_new[ids[row]] = mvals[0] + labels_new[ids[row]] = np.bincount(rvals).argmax() if rempty > 0: # should not happen print("WARNING: row empty: " + str(rempty)) diff --git a/recon_surf/spherically_project.py b/recon_surf/spherically_project.py index c0706d47a..0de5c4649 100644 --- a/recon_surf/spherically_project.py +++ b/recon_surf/spherically_project.py @@ -145,7 +145,7 @@ def get_flipped_area(tria): return area fem = Solver(tria, lump=False, use_cholmod=use_cholmod) - evals, evecs = fem.eigs(k=4) + evals, evecs = fem.eigs(k=4, rng=0) if debug: data = dict() diff --git a/requirements.txt b/requirements.txt index e59e917c4..cfea3ea10 100644 --- a/requirements.txt +++ b/requirements.txt @@ -60,7 +60,7 @@ multipledispatch==1.0.0 narwhals==2.21.0 networkx==3.6.1 neurolit==0.6.1 -neuroreg==0.6.1 +neuroreg==0.6.2 nibabel==5.4.2 numpy==2.4.4 packaging==26.2