From 918a4b49923d1511e1abdf81c67d5bcc57d99760 Mon Sep 17 00:00:00 2001 From: Steven Chen <117523987+perctrix@users.noreply.github.com> Date: Sun, 19 Oct 2025 12:33:54 -0400 Subject: [PATCH 1/8] Added random_roi --- mipcandy/data/inspection.py | 27 +++++++++++++++++++++++---- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/mipcandy/data/inspection.py b/mipcandy/data/inspection.py index 5f21956..ba24755 100644 --- a/mipcandy/data/inspection.py +++ b/mipcandy/data/inspection.py @@ -194,9 +194,27 @@ def roi(self, i: int, *, percentile: float = .95) -> tuple[int, int, int, int] | roi.append(position + offset + right) return tuple(roi) - def crop_roi(self, i: int, *, percentile: float = .95) -> tuple[torch.Tensor, torch.Tensor]: + def random_roi(self, i: int, *, percentile: float = .95) -> tuple[int, int, int, int] | tuple[ + int, int, int, int, int, int]: + annotation = self._annotations[i] + roi_shape = self.roi_shape(percentile=percentile) + roi = [] + for dim_idx, (dim_size, patch_size) in enumerate(zip(annotation.shape, roi_shape)): + left = patch_size // 2 + right = patch_size - left + min_center = left + max_center = dim_size - right + center = torch.randint(min_center, max_center + 1, (1,)).item() + roi.append(center - left) + roi.append(center + right) + return tuple(roi) + + def crop_roi(self, i: int, *, percentile: float = .95, random_patch: bool = False) -> tuple[torch.Tensor, torch.Tensor]: image, label = self._dataset[i] - roi = self.roi(i, percentile=percentile) + if random_patch: + roi = self.random_roi(i, percentile=percentile) + else: + roi = self.roi(i, percentile=percentile) return crop(image.unsqueeze(0), roi).squeeze(0), crop(label.unsqueeze(0), roi).squeeze(0) @@ -225,10 +243,11 @@ def inspect(dataset: SupervisedDataset, *, background: int = 0) -> InspectionAnn class ROIDataset(SupervisedDataset[list[torch.Tensor]]): - def __init__(self, annotations: InspectionAnnotations, *, percentile: float = .95) -> None: + def __init__(self, annotations: InspectionAnnotations, *, percentile: float = .95, random_patch: bool = False) -> None: super().__init__([], []) self._annotations: InspectionAnnotations = annotations self._percentile: float = percentile + self.random_patch: bool = random_patch @override def __len__(self) -> int: @@ -240,4 +259,4 @@ def construct_new(self, images: list[torch.Tensor], labels: list[torch.Tensor]) @override def load(self, idx: int) -> tuple[torch.Tensor, torch.Tensor]: - return self._annotations.crop_roi(idx, percentile=self._percentile) + return self._annotations.crop_roi(idx, percentile=self._percentile, random_patch=self.random_patch) From 7c6b0a405393c2f0bdabcaaf1397a9156c184c8d Mon Sep 17 00:00:00 2001 From: Steven Chen <117523987+perctrix@users.noreply.github.com> Date: Sun, 19 Oct 2025 12:34:48 -0400 Subject: [PATCH 2/8] Added foreground oversampling --- mipcandy/data/inspection.py | 52 ++++++++++++++++++++++++++++++++----- 1 file changed, 46 insertions(+), 6 deletions(-) diff --git a/mipcandy/data/inspection.py b/mipcandy/data/inspection.py index ba24755..a8648ec 100644 --- a/mipcandy/data/inspection.py +++ b/mipcandy/data/inspection.py @@ -27,6 +27,7 @@ class InspectionAnnotation(object): shape: tuple[int, ...] foreground_bbox: tuple[int, int, int, int] | tuple[int, int, int, int, int, int] ids: tuple[int, ...] + foreground_samples: torch.Tensor | None = None def foreground_shape(self) -> tuple[int, int] | tuple[int, int, int]: r = (self.foreground_bbox[1] - self.foreground_bbox[0], self.foreground_bbox[3] - self.foreground_bbox[2]) @@ -209,10 +210,37 @@ def random_roi(self, i: int, *, percentile: float = .95) -> tuple[int, int, int, roi.append(center + right) return tuple(roi) - def crop_roi(self, i: int, *, percentile: float = .95, random_patch: bool = False) -> tuple[torch.Tensor, torch.Tensor]: + def foreground_guided_random_roi(self, i: int, *, percentile: float = .95) -> tuple[int, int, int, int] | tuple[ + int, int, int, int, int, int]: + annotation = self._annotations[i] + roi_shape = self.roi_shape(percentile=percentile) + + if annotation.foreground_samples is None or len(annotation.foreground_samples) == 0: + return self.random_roi(i, percentile=percentile) + + fg_idx = torch.randint(0, len(annotation.foreground_samples), (1,)).item() + fg_position = annotation.foreground_samples[fg_idx] + + roi = [] + for dim_idx, (fg_pos, dim_size, patch_size) in enumerate( + zip(fg_position.tolist(), annotation.shape, roi_shape) + ): + left = patch_size // 2 + right = patch_size - left + center = max(left, min(fg_pos, dim_size - right)) + roi.append(center - left) + roi.append(center + right) + + return tuple(roi) + + def crop_roi(self, i: int, *, percentile: float = .95, random_patch: bool = False, + force_foreground: bool = False) -> tuple[torch.Tensor, torch.Tensor]: image, label = self._dataset[i] if random_patch: - roi = self.random_roi(i, percentile=percentile) + if force_foreground: + roi = self.foreground_guided_random_roi(i, percentile=percentile) + else: + roi = self.random_roi(i, percentile=percentile) else: roi = self.roi(i, percentile=percentile) return crop(image.unsqueeze(0), roi).squeeze(0), crop(label.unsqueeze(0), roi).squeeze(0) @@ -227,27 +255,38 @@ def load_inspection_annotations(path: str | PathLike[str]) -> InspectionAnnotati )) -def inspect(dataset: SupervisedDataset, *, background: int = 0) -> InspectionAnnotations: +def inspect(dataset: SupervisedDataset, *, background: int = 0, num_foreground_samples: int = 1000) -> InspectionAnnotations: r = [] for _, label in dataset: indices = (label != background).nonzero() mins = indices.min(dim=0)[0].tolist() maxs = indices.max(dim=0)[0].tolist() bbox = (mins[1], maxs[1], mins[2], maxs[2]) + if len(indices) > 0: + if len(indices) > num_foreground_samples: + sampled_idx = torch.randperm(len(indices))[:num_foreground_samples] + foreground_samples = indices[sampled_idx] + else: + foreground_samples = indices + else: + foreground_samples = None r.append(InspectionAnnotation( label.shape[1:], bbox if label.ndim == 3 else bbox + (mins[3], maxs[3]), - tuple(label.unique()) + tuple(label.unique()), + foreground_samples )) return InspectionAnnotations(dataset, background, *r, device=dataset.device()) class ROIDataset(SupervisedDataset[list[torch.Tensor]]): - def __init__(self, annotations: InspectionAnnotations, *, percentile: float = .95, random_patch: bool = False) -> None: + def __init__(self, annotations: InspectionAnnotations, *, percentile: float = .95, + random_patch: bool = False, foreground_oversample_percent: float = 0.33) -> None: super().__init__([], []) self._annotations: InspectionAnnotations = annotations self._percentile: float = percentile self.random_patch: bool = random_patch + self._fg_oversample: float = foreground_oversample_percent @override def __len__(self) -> int: @@ -259,4 +298,5 @@ def construct_new(self, images: list[torch.Tensor], labels: list[torch.Tensor]) @override def load(self, idx: int) -> tuple[torch.Tensor, torch.Tensor]: - return self._annotations.crop_roi(idx, percentile=self._percentile, random_patch=self.random_patch) + force_fg = torch.rand(1).item() < self._fg_oversample if self.random_patch else False + return self._annotations.crop_roi(idx, percentile=self._percentile, random_patch=self.random_patch, force_foreground=force_fg) From cc34a3af2bb5774a8ccd7e03241c97b5a8c7ffe3 Mon Sep 17 00:00:00 2001 From: Steven Chen <117523987+perctrix@users.noreply.github.com> Date: Sun, 19 Oct 2025 18:09:36 -0400 Subject: [PATCH 3/8] Adjust the inspect function to support the minimum coverage parameter and optimize the foreground sample selection logic --- mipcandy/data/inspection.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/mipcandy/data/inspection.py b/mipcandy/data/inspection.py index a8648ec..4bef0a8 100644 --- a/mipcandy/data/inspection.py +++ b/mipcandy/data/inspection.py @@ -255,7 +255,8 @@ def load_inspection_annotations(path: str | PathLike[str]) -> InspectionAnnotati )) -def inspect(dataset: SupervisedDataset, *, background: int = 0, num_foreground_samples: int = 1000) -> InspectionAnnotations: +def inspect(dataset: SupervisedDataset, *, background: int = 0, num_foreground_samples: int = 10000, + min_percent_coverage: float = 0.01) -> InspectionAnnotations: r = [] for _, label in dataset: indices = (label != background).nonzero() @@ -263,11 +264,14 @@ def inspect(dataset: SupervisedDataset, *, background: int = 0, num_foreground_s maxs = indices.max(dim=0)[0].tolist() bbox = (mins[1], maxs[1], mins[2], maxs[2]) if len(indices) > 0: - if len(indices) > num_foreground_samples: - sampled_idx = torch.randperm(len(indices))[:num_foreground_samples] - foreground_samples = indices[sampled_idx] - else: + target_samples = min(len(indices), + max(num_foreground_samples, + int(np.ceil(len(indices) * min_percent_coverage)))) + if len(indices) <= target_samples: foreground_samples = indices + else: + sampled_idx = torch.randperm(len(indices))[:target_samples] + foreground_samples = indices[sampled_idx] else: foreground_samples = None r.append(InspectionAnnotation( From b14ebe7b4b0b0c4c971bf7f7ee3a37c27ba0f6b8 Mon Sep 17 00:00:00 2001 From: Steven Chen <117523987+perctrix@users.noreply.github.com> Date: Sun, 19 Oct 2025 18:20:54 -0400 Subject: [PATCH 4/8] Added min/max foreground voxel number limit --- mipcandy/data/inspection.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/mipcandy/data/inspection.py b/mipcandy/data/inspection.py index 4bef0a8..b7ab282 100644 --- a/mipcandy/data/inspection.py +++ b/mipcandy/data/inspection.py @@ -255,8 +255,8 @@ def load_inspection_annotations(path: str | PathLike[str]) -> InspectionAnnotati )) -def inspect(dataset: SupervisedDataset, *, background: int = 0, num_foreground_samples: int = 10000, - min_percent_coverage: float = 0.01) -> InspectionAnnotations: +def inspect(dataset: SupervisedDataset, *, background: int = 0, min_foreground_samples: int = 500, + max_foreground_samples: int = 10000, min_percent_coverage: float = 0.01) -> InspectionAnnotations: r = [] for _, label in dataset: indices = (label != background).nonzero() @@ -264,12 +264,13 @@ def inspect(dataset: SupervisedDataset, *, background: int = 0, num_foreground_s maxs = indices.max(dim=0)[0].tolist() bbox = (mins[1], maxs[1], mins[2], maxs[2]) if len(indices) > 0: - target_samples = min(len(indices), - max(num_foreground_samples, - int(np.ceil(len(indices) * min_percent_coverage)))) - if len(indices) <= target_samples: + if len(indices) <= min_foreground_samples: foreground_samples = indices else: + target_samples = min( + max_foreground_samples, + max(min_foreground_samples, int(np.ceil(len(indices) * min_percent_coverage))) + ) sampled_idx = torch.randperm(len(indices))[:target_samples] foreground_samples = indices[sampled_idx] else: From b3c8d70afb97f3add5a912a24b46dbf435c79651 Mon Sep 17 00:00:00 2001 From: Steven Chen Date: Tue, 4 Nov 2025 20:34:34 -0500 Subject: [PATCH 5/8] merge from main --- mipcandy/data/inspection.py | 92 ++++++++++++++++++++++--------------- 1 file changed, 55 insertions(+), 37 deletions(-) diff --git a/mipcandy/data/inspection.py b/mipcandy/data/inspection.py index b7ab282..71e5889 100644 --- a/mipcandy/data/inspection.py +++ b/mipcandy/data/inspection.py @@ -1,10 +1,12 @@ -from dataclasses import dataclass +from dataclasses import dataclass, asdict +from json import dump, load from os import PathLike -from typing import Sequence, override, Callable, Self +from typing import Sequence, override, Callable, Self, Any import numpy as np import torch -from pandas import DataFrame +from rich.console import Console +from rich.progress import Progress, SpinnerColumn from torch import nn from mipcandy.data.dataset import SupervisedDataset @@ -38,6 +40,9 @@ def center_of_foreground(self) -> tuple[int, int] | tuple[int, int, int]: round((self.foreground_bbox[3] + self.foreground_bbox[2]) * .5)) return r if len(self.shape) == 2 else r + (round((self.foreground_bbox[5] + self.foreground_bbox[4]) * .5),) + def to_dict(self) -> dict[str, tuple[int, ...]]: + return asdict(self) + class InspectionAnnotations(HasDevice, Sequence[InspectionAnnotation]): def __init__(self, dataset: SupervisedDataset, background: int, *annotations: InspectionAnnotation, @@ -66,10 +71,8 @@ def __len__(self) -> int: return len(self._annotations) def save(self, path: str | PathLike[str]) -> None: - r = [] - for annotation in self._annotations: - r.append({"foreground_bbox": annotation.foreground_bbox, "ids": annotation.ids}) - DataFrame(r).to_csv(path, index=False) + with open(path, "w") as f: + dump({"background": self._background, "annotations": self._annotations}, f) def _get_shapes(self, get_shape: Callable[[InspectionAnnotation], tuple[int, ...]]) -> tuple[ tuple[int, ...] | None, tuple[int, ...], tuple[int, ...]]: @@ -105,7 +108,7 @@ def statistical_foreground_shape(self, *, percentile: float = .95) -> tuple[int, depths, heights, widths = self.foreground_shapes() percentile *= 100 sfs = (round(np.percentile(heights, percentile)), round(np.percentile(widths, percentile))) - self._statistical_foreground_shape = (round(np.percentile(heights, percentile)),) + sfs if depths else sfs + self._statistical_foreground_shape = (round(np.percentile(depths, percentile)),) + sfs if depths else sfs return self._statistical_foreground_shape def crop_foreground(self, i: int, *, expand_ratio: float = 1) -> tuple[torch.Tensor, torch.Tensor]: @@ -165,6 +168,14 @@ def center_of_foregrounds_offsets(self) -> tuple[int, int] | tuple[int, int, int return self._foreground_offsets def set_roi_shape(self, roi_shape: tuple[int, int] | tuple[int, int, int] | None) -> None: + if roi_shape is not None: + depths, heights, widths = self.shapes() + if depths: + if roi_shape[0] > min(depths) or roi_shape[1] > min(heights) or roi_shape[2] > min(widths): + raise ValueError(f"ROI shape {roi_shape} exceeds minimum image shape ({min(depths)}, {min(heights)}, {min(widths)})") + else: + if roi_shape[0] > min(heights) or roi_shape[1] > min(widths): + raise ValueError(f"ROI shape {roi_shape} exceeds minimum image shape ({min(heights)}, {min(widths)})") self._roi_shape = roi_shape def roi_shape(self, *, percentile: float = .95) -> tuple[int, int] | tuple[int, int, int]: @@ -246,41 +257,48 @@ def crop_roi(self, i: int, *, percentile: float = .95, random_patch: bool = Fals return crop(image.unsqueeze(0), roi).squeeze(0), crop(label.unsqueeze(0), roi).squeeze(0) -def load_inspection_annotations(path: str | PathLike[str]) -> InspectionAnnotations: - df = DataFrame.from_csv(path) - return InspectionAnnotations(*( - InspectionAnnotation( - tuple(row["shape"]), format_bbox(row["foreground_bbox"]), tuple(row["ids"]) - ) for _, row in df.iterrows() +def _lists_to_tuples(pairs: Sequence[tuple[str, Any]]) -> dict[str, Any]: + return {k: tuple(v) if isinstance(v, list) else v for k, v in pairs} + + +def load_inspection_annotations(path: str | PathLike[str], dataset: SupervisedDataset) -> InspectionAnnotations: + with open(path) as f: + obj = load(f, object_pairs_hook=_lists_to_tuples) + return InspectionAnnotations(dataset, obj["background"], *( + InspectionAnnotation(**row) for row in obj["annotations"] )) def inspect(dataset: SupervisedDataset, *, background: int = 0, min_foreground_samples: int = 500, - max_foreground_samples: int = 10000, min_percent_coverage: float = 0.01) -> InspectionAnnotations: + max_foreground_samples: int = 10000, min_percent_coverage: float = 0.01, + console: Console = Console()) -> InspectionAnnotations: r = [] - for _, label in dataset: - indices = (label != background).nonzero() - mins = indices.min(dim=0)[0].tolist() - maxs = indices.max(dim=0)[0].tolist() - bbox = (mins[1], maxs[1], mins[2], maxs[2]) - if len(indices) > 0: - if len(indices) <= min_foreground_samples: - foreground_samples = indices + with Progress(*Progress.get_default_columns(), SpinnerColumn(), console=console) as progress: + task = progress.add_task("Inspecting dataset...", total=len(dataset)) + for _, label in dataset: + progress.update(task, advance=1, description=f"Inspecting dataset {tuple(label.shape)}") + indices = (label != background).nonzero() + mins = indices.min(dim=0)[0].tolist() + maxs = indices.max(dim=0)[0].tolist() + bbox = (mins[1], maxs[1], mins[2], maxs[2]) + if len(indices) > 0: + if len(indices) <= min_foreground_samples: + foreground_samples = indices + else: + target_samples = min( + max_foreground_samples, + max(min_foreground_samples, int(np.ceil(len(indices) * min_percent_coverage))) + ) + sampled_idx = torch.randperm(len(indices))[:target_samples] + foreground_samples = indices[sampled_idx] else: - target_samples = min( - max_foreground_samples, - max(min_foreground_samples, int(np.ceil(len(indices) * min_percent_coverage))) - ) - sampled_idx = torch.randperm(len(indices))[:target_samples] - foreground_samples = indices[sampled_idx] - else: - foreground_samples = None - r.append(InspectionAnnotation( - label.shape[1:], - bbox if label.ndim == 3 else bbox + (mins[3], maxs[3]), - tuple(label.unique()), - foreground_samples - )) + foreground_samples = None + r.append(InspectionAnnotation( + label.shape[1:], + bbox if label.ndim == 3 else bbox + (mins[3], maxs[3]), + tuple(label.unique()), + foreground_samples + )) return InspectionAnnotations(dataset, background, *r, device=dataset.device()) From 30fcdc6c971aeab712eb3e84b467c5d19f60259d Mon Sep 17 00:00:00 2001 From: Steven Chen Date: Tue, 11 Nov 2025 12:07:06 -0500 Subject: [PATCH 6/8] Refactor random sampling into RandomROIDataset subclass - Merged latest changes from main - Fixed Tensor serialization in save/load methods - Created RandomROIDataset as child class of ROIDataset - Moved all random sampling logic into the subclass - Kept ROIDataset and InspectionAnnotations minimal and clean --- mipcandy/data/__init__.py | 2 +- mipcandy/data/inspection.py | 115 +++++++++++++++++++----------------- 2 files changed, 62 insertions(+), 55 deletions(-) diff --git a/mipcandy/data/__init__.py b/mipcandy/data/__init__.py index 2426a32..76c5057 100644 --- a/mipcandy/data/__init__.py +++ b/mipcandy/data/__init__.py @@ -4,6 +4,6 @@ from mipcandy.data.download import download_dataset from mipcandy.data.geometric import ensure_num_dimensions, orthographic_views, aggregate_orthographic_views, crop from mipcandy.data.inspection import InspectionAnnotation, InspectionAnnotations, load_inspection_annotations, \ - inspect, ROIDataset + inspect, ROIDataset, RandomROIDataset from mipcandy.data.io import resample_to_isotropic, load_image, save_image from mipcandy.data.visualization import visualize2d, visualize3d, overlay diff --git a/mipcandy/data/inspection.py b/mipcandy/data/inspection.py index 757b959..423a78d 100644 --- a/mipcandy/data/inspection.py +++ b/mipcandy/data/inspection.py @@ -209,54 +209,9 @@ def roi(self, i: int, *, percentile: float = .95) -> tuple[int, int, int, int] | roi.append(position + offset + right) return tuple(roi) - def random_roi(self, i: int, *, percentile: float = .95) -> tuple[int, int, int, int] | tuple[ - int, int, int, int, int, int]: - annotation = self._annotations[i] - roi_shape = self.roi_shape(percentile=percentile) - roi = [] - for dim_idx, (dim_size, patch_size) in enumerate(zip(annotation.shape, roi_shape)): - left = patch_size // 2 - right = patch_size - left - min_center = left - max_center = dim_size - right - center = torch.randint(min_center, max_center + 1, (1,)).item() - roi.append(center - left) - roi.append(center + right) - return tuple(roi) - - def foreground_guided_random_roi(self, i: int, *, percentile: float = .95) -> tuple[int, int, int, int] | tuple[ - int, int, int, int, int, int]: - annotation = self._annotations[i] - roi_shape = self.roi_shape(percentile=percentile) - - if annotation.foreground_samples is None or len(annotation.foreground_samples) == 0: - return self.random_roi(i, percentile=percentile) - - fg_idx = torch.randint(0, len(annotation.foreground_samples), (1,)).item() - fg_position = annotation.foreground_samples[fg_idx] - - roi = [] - for dim_idx, (fg_pos, dim_size, patch_size) in enumerate( - zip(fg_position.tolist(), annotation.shape, roi_shape) - ): - left = patch_size // 2 - right = patch_size - left - center = max(left, min(fg_pos, dim_size - right)) - roi.append(center - left) - roi.append(center + right) - - return tuple(roi) - - def crop_roi(self, i: int, *, percentile: float = .95, random_patch: bool = False, - force_foreground: bool = False) -> tuple[torch.Tensor, torch.Tensor]: + def crop_roi(self, i: int, *, percentile: float = .95) -> tuple[torch.Tensor, torch.Tensor]: image, label = self._dataset[i] - if random_patch: - if force_foreground: - roi = self.foreground_guided_random_roi(i, percentile=percentile) - else: - roi = self.random_roi(i, percentile=percentile) - else: - roi = self.roi(i, percentile=percentile) + roi = self.roi(i, percentile=percentile) return crop(image.unsqueeze(0), roi).squeeze(0), crop(label.unsqueeze(0), roi).squeeze(0) @@ -309,13 +264,10 @@ def inspect(dataset: SupervisedDataset, *, background: int = 0, min_foreground_s class ROIDataset(SupervisedDataset[list[torch.Tensor]]): - def __init__(self, annotations: InspectionAnnotations, *, percentile: float = .95, - random_patch: bool = False, foreground_oversample_percent: float = 0.33) -> None: + def __init__(self, annotations: InspectionAnnotations, *, percentile: float = .95) -> None: super().__init__([], []) self._annotations: InspectionAnnotations = annotations self._percentile: float = percentile - self.random_patch: bool = random_patch - self._fg_oversample: float = foreground_oversample_percent @override def __len__(self) -> int: @@ -323,9 +275,64 @@ def __len__(self) -> int: @override def construct_new(self, images: list[torch.Tensor], labels: list[torch.Tensor]) -> Self: - return ROIDataset(self._annotations) + return self.__class__(self._annotations, percentile=self._percentile) + + @override + def load(self, idx: int) -> tuple[torch.Tensor, torch.Tensor]: + return self._annotations.crop_roi(idx, percentile=self._percentile) + + +class RandomROIDataset(ROIDataset): + def __init__(self, annotations: InspectionAnnotations, *, percentile: float = .95, + foreground_oversample_percent: float = 0.33) -> None: + super().__init__(annotations, percentile=percentile) + self._fg_oversample: float = foreground_oversample_percent + + def _random_roi(self, idx: int) -> tuple[int, int, int, int] | tuple[int, int, int, int, int, int]: + annotation = self._annotations[idx] + roi_shape = self._annotations.roi_shape(percentile=self._percentile) + roi = [] + for dim_size, patch_size in zip(annotation.shape, roi_shape): + left = patch_size // 2 + right = patch_size - left + min_center = left + max_center = dim_size - right + center = torch.randint(min_center, max_center + 1, (1,)).item() + roi.append(center - left) + roi.append(center + right) + return tuple(roi) + + def _foreground_guided_random_roi(self, idx: int) -> tuple[int, int, int, int] | tuple[ + int, int, int, int, int, int]: + annotation = self._annotations[idx] + roi_shape = self._annotations.roi_shape(percentile=self._percentile) + + if annotation.foreground_samples is None or len(annotation.foreground_samples) == 0: + return self._random_roi(idx) + + fg_idx = torch.randint(0, len(annotation.foreground_samples), (1,)).item() + fg_position = annotation.foreground_samples[fg_idx] + + roi = [] + for fg_pos, dim_size, patch_size in zip(fg_position.tolist(), annotation.shape, roi_shape): + left = patch_size // 2 + right = patch_size - left + center = max(left, min(fg_pos, dim_size - right)) + roi.append(center - left) + roi.append(center + right) + return tuple(roi) + + @override + def construct_new(self, images: list[torch.Tensor], labels: list[torch.Tensor]) -> Self: + return self.__class__(self._annotations, percentile=self._percentile, + foreground_oversample_percent=self._fg_oversample) @override def load(self, idx: int) -> tuple[torch.Tensor, torch.Tensor]: - force_fg = torch.rand(1).item() < self._fg_oversample if self.random_patch else False - return self._annotations.crop_roi(idx, percentile=self._percentile, random_patch=self.random_patch, force_foreground=force_fg) + image, label = self._annotations._dataset[idx] + force_fg = torch.rand(1).item() < self._fg_oversample + if force_fg: + roi = self._foreground_guided_random_roi(idx) + else: + roi = self._random_roi(idx) + return crop(image.unsqueeze(0), roi).squeeze(0), crop(label.unsqueeze(0), roi).squeeze(0) From 91b7e78515217c4fdbd55b898fe845de8855248b Mon Sep 17 00:00:00 2001 From: Steven Chen Date: Tue, 11 Nov 2025 12:31:56 -0500 Subject: [PATCH 7/8] merge mipcandy docs submodule --- mipcandy-docs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mipcandy-docs b/mipcandy-docs index c127042..d6014ac 160000 --- a/mipcandy-docs +++ b/mipcandy-docs @@ -1 +1 @@ -Subproject commit c1270425a4835962f6b99504845501b0d9f47d54 +Subproject commit d6014ac2041b82ab7c48947d25a0edfcd74c78e2 From 5002bb2dfb8172f997ff56eb02d94840b6dfe5eb Mon Sep 17 00:00:00 2001 From: Steven Chen Date: Mon, 1 Dec 2025 19:39:59 -0500 Subject: [PATCH 8/8] Replace `foreground_samples` tensor with intrinsic tuple type --- mipcandy/data/inspection.py | 33 +++++++++++++++++---------------- 1 file changed, 17 insertions(+), 16 deletions(-) diff --git a/mipcandy/data/inspection.py b/mipcandy/data/inspection.py index 423a78d..3d2130e 100644 --- a/mipcandy/data/inspection.py +++ b/mipcandy/data/inspection.py @@ -29,7 +29,7 @@ class InspectionAnnotation(object): shape: tuple[int, ...] foreground_bbox: tuple[int, int, int, int] | tuple[int, int, int, int, int, int] ids: tuple[int, ...] - foreground_samples: torch.Tensor | None = None + foreground_samples: tuple[tuple[int, ...], ...] | None = None def foreground_shape(self) -> tuple[int, int] | tuple[int, int, int]: r = (self.foreground_bbox[1] - self.foreground_bbox[0], self.foreground_bbox[3] - self.foreground_bbox[2]) @@ -40,11 +40,8 @@ def center_of_foreground(self) -> tuple[int, int] | tuple[int, int, int]: round((self.foreground_bbox[3] + self.foreground_bbox[2]) * .5)) return r if len(self.shape) == 2 else r + (round((self.foreground_bbox[5] + self.foreground_bbox[4]) * .5),) - def to_dict(self) -> dict[str, Any]: - d = asdict(self) - if self.foreground_samples is not None: - d["foreground_samples"] = self.foreground_samples.tolist() - return d + def to_dict(self) -> dict[str, tuple[int, ...]]: + return asdict(self) class InspectionAnnotations(HasDevice, Sequence[InspectionAnnotation]): @@ -215,19 +212,22 @@ def crop_roi(self, i: int, *, percentile: float = .95) -> tuple[torch.Tensor, to return crop(image.unsqueeze(0), roi).squeeze(0), crop(label.unsqueeze(0), roi).squeeze(0) +def _list_to_tuple(v: Any) -> Any: + if isinstance(v, list): + return tuple(_list_to_tuple(item) for item in v) + return v + + def _lists_to_tuples(pairs: Sequence[tuple[str, Any]]) -> dict[str, Any]: - return {k: tuple(v) if isinstance(v, list) and k != "foreground_samples" else v for k, v in pairs} + return {k: _list_to_tuple(v) for k, v in pairs} def load_inspection_annotations(path: str | PathLike[str], dataset: SupervisedDataset) -> InspectionAnnotations: with open(path) as f: obj = load(f, object_pairs_hook=_lists_to_tuples) - annotations = [] - for row in obj["annotations"]: - if row.get("foreground_samples") is not None: - row["foreground_samples"] = torch.tensor(row["foreground_samples"]) - annotations.append(InspectionAnnotation(**row)) - return InspectionAnnotations(dataset, obj["background"], *annotations) + return InspectionAnnotations(dataset, obj["background"], *( + InspectionAnnotation(**row) for row in obj["annotations"] + )) def inspect(dataset: SupervisedDataset, *, background: int = 0, min_foreground_samples: int = 500, @@ -244,14 +244,15 @@ def inspect(dataset: SupervisedDataset, *, background: int = 0, min_foreground_s bbox = (mins[1], maxs[1], mins[2], maxs[2]) if len(indices) > 0: if len(indices) <= min_foreground_samples: - foreground_samples = indices + sampled = indices else: target_samples = min( max_foreground_samples, max(min_foreground_samples, int(np.ceil(len(indices) * min_percent_coverage))) ) sampled_idx = torch.randperm(len(indices))[:target_samples] - foreground_samples = indices[sampled_idx] + sampled = indices[sampled_idx] + foreground_samples = tuple(tuple(coord.tolist()) for coord in sampled) else: foreground_samples = None r.append(InspectionAnnotation( @@ -314,7 +315,7 @@ def _foreground_guided_random_roi(self, idx: int) -> tuple[int, int, int, int] | fg_position = annotation.foreground_samples[fg_idx] roi = [] - for fg_pos, dim_size, patch_size in zip(fg_position.tolist(), annotation.shape, roi_shape): + for fg_pos, dim_size, patch_size in zip(fg_position, annotation.shape, roi_shape): left = patch_size // 2 right = patch_size - left center = max(left, min(fg_pos, dim_size - right))