|
24 | 24 | distance_transform_edt, |
25 | 25 | gaussian_filter, |
26 | 26 | generate_binary_structure, |
| 27 | + maximum_filter, |
| 28 | + minimum_filter, |
27 | 29 | sobel, |
28 | 30 | ) |
29 | 31 | from scipy.spatial.distance import cdist |
@@ -598,6 +600,145 @@ def np_dilate_msk_euclid(arr: np.ndarray, n_pixel: int = 3, use_crop=True, label |
598 | 600 | return arr |
599 | 601 |
|
600 | 602 |
|
| 603 | +def _np_voronoi_ties(arr: UINTARRAY, indices: np.ndarray, nearest: np.ndarray, zoom: Sequence[float] | None) -> np.ndarray: |
| 604 | + """Finds the voxels that are equidistant to two differently labelled regions. |
| 605 | +
|
| 606 | + ``distance_transform_edt`` picks one nearest source per voxel and never reports that the choice |
| 607 | + was arbitrary, so the ties have to be recovered afterwards. Only voxels next to a change in the |
| 608 | + nearest-label assignment can sit on a boundary between two regions, so the search is restricted |
| 609 | + to those and then each of their neighbours' nearest sources is tested for being at exactly the |
| 610 | + same distance. Any neighbour's source is a real foreground voxel, so a hit is always a genuine |
| 611 | + tie -- the test can miss one, but never invent one. |
| 612 | +
|
| 613 | + Args: |
| 614 | + arr (UINTARRAY): The labelled input array. |
| 615 | + indices (np.ndarray): Feature transform of ``arr``, shape ``(ndim, *arr.shape)``. |
| 616 | + nearest (np.ndarray): ``arr`` gathered at ``indices``. |
| 617 | + zoom (Sequence[float] | None): Voxel spacing used for the distances. |
| 618 | +
|
| 619 | + Returns: |
| 620 | + np.ndarray: Boolean mask of the tied voxels. |
| 621 | + """ |
| 622 | + ties = np.zeros(arr.shape, dtype=bool) |
| 623 | + boundary = maximum_filter(nearest, size=3) != minimum_filter(nearest, size=3) |
| 624 | + pts = np.argwhere(boundary) |
| 625 | + if len(pts) == 0: |
| 626 | + return ties |
| 627 | + sampling = np.ones(arr.ndim) if zoom is None else np.asarray(zoom, dtype=float) |
| 628 | + limit = np.asarray(arr.shape) - 1 |
| 629 | + key = (slice(None), *pts.T) |
| 630 | + dist2 = (((indices[key].T - pts) * sampling) ** 2).sum(1) |
| 631 | + label = nearest[tuple(pts.T)] |
| 632 | + tied = np.zeros(len(pts), dtype=bool) |
| 633 | + for offset in itertools.product((-1, 0, 1), repeat=arr.ndim): |
| 634 | + if not any(offset): |
| 635 | + continue |
| 636 | + neighbour = np.clip(pts + offset, 0, limit) |
| 637 | + source = indices[(slice(None), *neighbour.T)] |
| 638 | + cand2 = (((source.T - pts) * sampling) ** 2).sum(1) |
| 639 | + tied |= (arr[tuple(source)] != label) & (np.abs(cand2 - dist2) <= 1e-9 * np.maximum(dist2, 1.0)) |
| 640 | + ties[tuple(pts[tied].T)] = True |
| 641 | + return ties |
| 642 | + |
| 643 | + |
| 644 | +def np_voronoi_labels( |
| 645 | + arr: UINTARRAY, |
| 646 | + label_ref: LABEL_REFERENCE = None, |
| 647 | + max_distance: float | None = None, |
| 648 | + zoom: Sequence[float] | None = None, |
| 649 | + signed_background: bool = True, |
| 650 | + tie_to_zero: bool = True, |
| 651 | +) -> INTARRAY: |
| 652 | + """Assigns every voxel the label of the nearest labelled region (a Voronoi partition). |
| 653 | +
|
| 654 | + Computed with a single Euclidean feature transform of the background: the nearest background |
| 655 | + voxel of ``arr == 0`` is exactly the nearest labelled voxel of ``arr``, so one pass yields the |
| 656 | + assignment for the whole volume, independent of how many labels there are. |
| 657 | +
|
| 658 | + By default the partition is signed: voxels that were already labelled keep their positive |
| 659 | + label, voxels filled in from the background carry the *negative* label of the region they were |
| 660 | + assigned to, so the original mask can still be recovered from the result. Voxels that are |
| 661 | + exactly equidistant to two different regions are set to 0 rather than being broken arbitrarily. |
| 662 | +
|
| 663 | + For a bounded expansion that overwrites the input in place and crops to the mask first, see |
| 664 | + :func:`np_dilate_msk_euclid`; this function is the unbounded, spacing-aware variant. |
| 665 | +
|
| 666 | + Args: |
| 667 | + arr (UINTARRAY): Labelled input array with background 0. |
| 668 | + label_ref (int | list[int] | None, optional): Labels to partition by. Everything else is |
| 669 | + treated as background. Defaults to None (all labels found in ``arr``). |
| 670 | + max_distance (float | None, optional): Leave background voxels further than this from any |
| 671 | + region at 0. In the units of ``zoom``, i.e. voxels when ``zoom`` is None. |
| 672 | + Defaults to None (no limit). |
| 673 | + zoom (Sequence[float] | None, optional): Voxel spacing, so distances are measured in mm on |
| 674 | + anisotropic images. Defaults to None (isotropic voxels). |
| 675 | + signed_background (bool, optional): If True, background voxels get the negated label of |
| 676 | + their region. Defaults to True. |
| 677 | + tie_to_zero (bool, optional): If True, voxels equidistant to two different regions are set |
| 678 | + to 0 instead of being assigned to one of them. Defaults to True. |
| 679 | +
|
| 680 | + Returns: |
| 681 | + INTARRAY: The partition, signed when ``signed_background`` is set. |
| 682 | +
|
| 683 | + Examples: |
| 684 | + >>> arr = np.zeros((5, 1, 1), dtype=np.uint8) |
| 685 | + >>> arr[0] = 1 |
| 686 | + >>> arr[4] = 2 |
| 687 | + >>> np_voronoi_labels(arr).ravel().tolist() |
| 688 | + [1, -1, 0, -2, 2] |
| 689 | + """ |
| 690 | + assert 2 <= arr.ndim <= 3, f"expected 2D or 3D, but got {arr.ndim}" |
| 691 | + assert np.min(arr) >= 0, f"expected non-negative labels, got {np.min(arr)}" |
| 692 | + assert zoom is None or len(zoom) == arr.ndim, f"zoom has to have {arr.ndim} entries, got {zoom}" |
| 693 | + if label_ref is not None: |
| 694 | + arr = np.where(np_isin(arr, label_ref), arr, 0) |
| 695 | + background = arr == 0 |
| 696 | + if background.all(): |
| 697 | + return np.zeros(arr.shape, dtype=np.int8 if signed_background else arr.dtype) |
| 698 | + |
| 699 | + want_distance = max_distance is not None |
| 700 | + result = distance_transform_edt(background, sampling=zoom, return_distances=want_distance, return_indices=True) |
| 701 | + distances, indices = result if want_distance else (None, result) |
| 702 | + nearest = arr[tuple(indices)] |
| 703 | + |
| 704 | + out = nearest.astype(np.min_scalar_type(-int(arr.max()) - 1) if signed_background else arr.dtype, copy=True) |
| 705 | + if signed_background: |
| 706 | + out[background] = -out[background] |
| 707 | + if want_distance: |
| 708 | + out[background & (distances > max_distance)] = 0 |
| 709 | + if tie_to_zero: |
| 710 | + out[_np_voronoi_ties(arr, indices, nearest, zoom)] = 0 |
| 711 | + return out |
| 712 | + |
| 713 | + |
| 714 | +def np_expand_labels( |
| 715 | + arr: UINTARRAY, |
| 716 | + distance: float | None = None, |
| 717 | + label_ref: LABEL_REFERENCE = None, |
| 718 | + zoom: Sequence[float] | None = None, |
| 719 | +) -> UINTARRAY: |
| 720 | + """Grows every label into the surrounding background, up to ``distance``. |
| 721 | +
|
| 722 | + Each background voxel within reach takes the label of the nearest region; labelled voxels are |
| 723 | + left alone, so labels never eat into each other. This is :func:`np_voronoi_labels` with the |
| 724 | + plain, unsigned semantics -- see there for the signed partition and the handling of voxels that |
| 725 | + are equidistant to two regions. |
| 726 | +
|
| 727 | + Args: |
| 728 | + arr (UINTARRAY): Labelled input array with background 0. |
| 729 | + distance (float | None, optional): How far to grow, in the units of ``zoom``, i.e. voxels |
| 730 | + when ``zoom`` is None. Defaults to None (fill the whole volume). |
| 731 | + label_ref (int | list[int] | None, optional): Labels to grow. Everything else is treated as |
| 732 | + background. Defaults to None (all labels found in ``arr``). |
| 733 | + zoom (Sequence[float] | None, optional): Voxel spacing, so ``distance`` is in mm on |
| 734 | + anisotropic images. Defaults to None (isotropic voxels). |
| 735 | +
|
| 736 | + Returns: |
| 737 | + UINTARRAY: A copy of ``arr`` with the labels grown. |
| 738 | + """ |
| 739 | + return np_voronoi_labels(arr, label_ref=label_ref, max_distance=distance, zoom=zoom, signed_background=False, tie_to_zero=False) |
| 740 | + |
| 741 | + |
601 | 742 | def np_dilate_msk( |
602 | 743 | arr: np.ndarray, |
603 | 744 | label_ref: LABEL_REFERENCE = None, |
|
0 commit comments