Args: points: landmark coordinates as ndarray/Tensor with shape (N, D), ordered as (Y, X) for 2D or (Z, Y, X) for 3D, where N is the number of landmarks and D is the spatial dimensionality. spatial_shape: spatial size as a sequence. If
(self, points: NdarrayOrTensor, spatial_shape: Sequence[int] | None = None)
| 804 | self.spatial_shape = None if spatial_shape is None else tuple(int(s) for s in spatial_shape) |
| 805 | |
| 806 | def __call__(self, points: NdarrayOrTensor, spatial_shape: Sequence[int] | None = None) -> NdarrayOrTensor: |
| 807 | """ |
| 808 | Args: |
| 809 | points: landmark coordinates as ndarray/Tensor with shape (N, D), |
| 810 | ordered as (Y, X) for 2D or (Z, Y, X) for 3D, where N is the number |
| 811 | of landmarks and D is the spatial dimensionality. |
| 812 | spatial_shape: spatial size as a sequence. If None, uses the value provided at construction. |
| 813 | |
| 814 | Returns: |
| 815 | Heatmaps with shape (N, *spatial), one channel per landmark. |
| 816 | |
| 817 | Raises: |
| 818 | ValueError: if points shape/dimension or spatial_shape is invalid. |
| 819 | """ |
| 820 | original_points = points |
| 821 | points_t = convert_to_tensor(points, dtype=torch.float32, track_meta=False) |
| 822 | |
| 823 | if points_t.ndim != 2: |
| 824 | raise ValueError( |
| 825 | f"Argument `points` must be a 2D array with shape (num_points, spatial_dims), got shape {points_t.shape}." |
| 826 | ) |
| 827 | |
| 828 | if points_t.shape[-1] not in (2, 3): |
| 829 | raise ValueError("GenerateHeatmap only supports 2D or 3D landmarks.") |
| 830 | |
| 831 | device = points_t.device |
| 832 | num_points, spatial_dims = points_t.shape |
| 833 | |
| 834 | target_shape = self._resolve_spatial_shape(spatial_shape, spatial_dims) |
| 835 | sigma = self._resolve_sigma(spatial_dims) |
| 836 | |
| 837 | # Create sparse image with impulses at landmark locations |
| 838 | heatmap = torch.zeros((num_points, *target_shape), dtype=self.torch_dtype, device=device) |
| 839 | bounds_t = torch.as_tensor(target_shape, device=device, dtype=points_t.dtype) |
| 840 | |
| 841 | for idx, center in enumerate(points_t): |
| 842 | if not torch.isfinite(center).all(): |
| 843 | continue |
| 844 | if not ((center >= 0).all() and (center < bounds_t).all()): |
| 845 | continue |
| 846 | # Round to nearest integer for impulse placement, then clamp to valid index range |
| 847 | center_int = center.round().long() |
| 848 | # Clamp indices to [0, size-1] to avoid out-of-bounds (e.g., 9.7 rounds to 10 in size-10 array) |
| 849 | bounds_max = (bounds_t - 1).long() |
| 850 | center_int = torch.minimum(torch.maximum(center_int, torch.zeros_like(center_int)), bounds_max) |
| 851 | # Place impulse (use maximum in case of overlapping landmarks) |
| 852 | current_val = heatmap[idx][tuple(center_int)] |
| 853 | heatmap[idx][tuple(center_int)] = torch.maximum( |
| 854 | current_val, torch.tensor(1.0, dtype=self.torch_dtype, device=device) |
| 855 | ) |
| 856 | |
| 857 | # Apply Gaussian blur using GaussianFilter |
| 858 | # Reshape to (num_points, 1, *spatial) for per-channel filtering |
| 859 | heatmap_input = heatmap.unsqueeze(1) # Add channel dimension |
| 860 | |
| 861 | gaussian_filter = GaussianFilter( |
| 862 | spatial_dims=spatial_dims, sigma=sigma, truncated=self.truncated, approx="erf", requires_grad=False |
| 863 | ).to(device=device, dtype=self.torch_dtype) |
nothing calls this directly
no test coverage detected