Project: s41467-024-52414-2 | Function: match_tumors (python)

system: You are an expert Python programmer implementing algorithms from scientific papers.
Complete the masked function based on the algorithm description.
Follow Python best practices and maintain consistent code style.
Return **only** the complete Python function code.

user: Complete this Python function:

**DESCRIPTION:** Tumor matching algorithm using dual heuristics of centroid-to-centroid distance and intersection over reference (IoR) to track individual metastases across longitudinal MRI scans, enabling quantitative measurement of tumor dynamics over time

**KEY TERMS:** {
  "pred_centroids": "3D coordinates of predicted tumor centers",
  "target_centroids": "3D coordinates of ground truth tumor centers",
  "distance_threshold": "maximum centroid distance for matching (default 20mm)",
  "ior_threshold": "minimum intersection over reference ratio (default 0.1)",
  "match_by_distance": "tumor pairs matched by centroid proximity",
  "match_by_ior": "tumor pairs matched by volumetric overlap",
  "matched_pairs": "final matched tumor indices across timepoints"
}

**CONTEXT CODE:**     spacing: list = [1., 1., 1.]
) -> float:
    """
    Measure the total volume of all nonzero values in a 3D segmentation array.
    """
    units = torch.count_nonzero(y)
    volume = units * spacing[0] * spacing[1] * spacing[2]
    return volume


def measure_single_tumor_volume(
    y,
    tumor_id: int = 1,
    spacing=[1., 1., 1.]
) -> float:
    """
    Measure the volume of a single tumor in a 3D segmentation array.
    """
    #y_tumor = y[torch.where(y==tumor_id)]
    y_tumor = y[y==tumor_id]
    tumor_volume = measure_total_tumor_volume(y_tumor, spacing=spacing)
    return tumor_volume


def get_per_tumor_volumes(
    y,
    spacing=[1., 1., 1.],
    is_onehot=False,
    num_tumors=None  # number of true positive tumors
):
    if num_tumors is None:
        if is_onehot:
            num_tumors = y.shape[0]
        else:
            num_tumors = y.max().int().item()

    tumor_vol_list = torch.zeros(num_tumors)

    if is_onehot:
        for i in range(1,num_tumors):
            tumor_vol_list[i] = measure_total_tumor_volume(y[i,...], spacing=spacing)
    else:
        for i in range(1,num_tumors):
            tumor_vol_list[i] = measure_single_tumor_volume(y, tumor_id=i, spacing=spacing)

    return tumor_vol_list


### MATCHING TUMORS

def match_tumors(y, y_ref, is_onehot, distance_threshold, ior_threshold, keep_old):
    """
    labels and reference labels must already be in connected components format,
    either with each tumor labeled with an int or in onehot format with each
    channel representing a unique tumor.

    Args:
        y: labels to be matched to reference
        y_ref: reference labels
        is_onehot: bool, true if the labels is onehot-encoded
        distance_threshold: maximum distance between tumor centroids
        ior_threshold: minimum ior (intersection over reference) between tumors
        keep_old: whether to keep the unmatched tumors
    
    Returns:
        matched_img: the labels with tumors matched to reference
    """
    raise NotImplementedError("Function implementation has been masked")


def get_centroids(y, is_onehot):
    if is_onehot:
        num_tumors = y.shape[0] + 1
        centroids = np.zeros((num_tumors, 3))
        for tumor_idx in range(num_tumors):
            if tumor_idx == 0:  # ignore background
                continue
            region = skimage.measure.regionprops(y[tumor_idx].int().numpy())
            if len(region) > 0:
                for i in range(len(region[0].centroid)):
                    centroids[tumor_idx,i] = region[0].centroid[i]

    else:
        num_tumors = y.max().int().item() + 1
        centroids = np.zeros((num_tumors, 3))
        for tumor_idx in range(num_tumors):
            if tumor_idx == 0:  # ignore background
                continue
            y_tumor = (y == tumor_idx) + 0.
            region = skimage.measure.regionprops(y_tumor[0].int().numpy())
            if len(region) > 0:
                for i in range(len(region[0].centroid)):
                    centroids[tumor_idx,i] = region[0].centroid[i]

    return centroids


def get_all_centroid_distances(centroids1, centroids2, max_distance = 300):
    # TODO: vectorize these operations
    n1, n2 = centroids1.shape[0], centroids2.shape[0]
    all_distances = np.ones((n1, n2)) * max_distance
    for i in range(1, n1):  # start at one to ignore background
        for j in range(1, n2):
            all_distances[i, j] = np.linalg.norm(centroids1[i] - centroids2[j])
    return all_distances


def match_tumors_by_distance(all_distances, distance_threshold = 5.):
    tumor_list = np.zeros(all_distances.shape[0])

    for i in range(all_distances.shape[0]):
        min_idx = np.argmin(all_distances[i])
        if all_distances[i, min_idx] <= np.min(all_distances[:,min_idx]) and all_distances[i, min_idx] <= distance_threshold:
            tumor_list[i] = min_idx
    
    return tumor_list.astype(int)


def get_all_ior(y, y_o, is_onehot):
    if is_onehot:
        n1, n2 = y.shape[0] + 1, y_o.shape[0] + 1
        all_ior = np.zeros((n1, n2))
        
        for i in range(1, n1):
            for j in range(1, n2):
                intersection = torch.sum(y[i] * y_o[j])
                reference = torch.sum(y[i])
                all_ior[i, j] = (intersection)/reference

    else:
        n1, n2 = y.max().int().item() + 1, y_o.max().int().item() + 1
        all_ior = np.zeros((n1, n2))
        
        for i in range(1, n1):
            for j in range(1, n2):
                y_tumor = (y == i) + 0.
                y_o_tumor = (y_o == j) + 0.
                intersection = torch.sum(y_tumor * y_o_tumor)
                reference = torch.sum(y_tumor)
                all_ior[i, j] = (intersection)/reference
        
    return all_ior


def get_all_iou(y, y_o, is_onehot):
    if is_onehot:
        n1, n2 = y.shape[0] + 1, y_o.shape[0] + 1
        all_iou = np.zeros((n1, n2))

        for i in range(1, n1):


Complete the function implementation replacing "raise NotImplementedError".