From 31a254a123757ec5df8ccb46f88d42c2f16c4c3b Mon Sep 17 00:00:00 2001 From: Weishan Li Date: Wed, 10 Jun 2026 09:34:54 -0600 Subject: [PATCH 1/9] Refactor crop generation module which is getting increasingly complicated as a subpackage, moved crop generator protocol to its own stand aloen module and center and point-centered crop generator functions in their respective modules under the new subpackage. --- .../ds_engine/crop_generators/center.py | 82 ++++++++++++++ .../crop_generators/point_centered.py | 103 ++++++++++++++++++ .../ds_engine/crop_generators/protocol.py | 25 +++++ 3 files changed, 210 insertions(+) create mode 100644 src/virtual_stain_flow/datasets/ds_engine/crop_generators/center.py create mode 100644 src/virtual_stain_flow/datasets/ds_engine/crop_generators/point_centered.py create mode 100644 src/virtual_stain_flow/datasets/ds_engine/crop_generators/protocol.py diff --git a/src/virtual_stain_flow/datasets/ds_engine/crop_generators/center.py b/src/virtual_stain_flow/datasets/ds_engine/crop_generators/center.py new file mode 100644 index 0000000..7831847 --- /dev/null +++ b/src/virtual_stain_flow/datasets/ds_engine/crop_generators/center.py @@ -0,0 +1,82 @@ +""" +""" + +from typing import Dict, List, Tuple + +from .protocol import CropMap +from ...base_dataset import BaseImageDataset +from ..ds_utils import ( + _get_active_channels, + _validate_same_dimensions_across_channels +) + + +def _compute_center_crop( + image_width: int, + image_height: int, + crop_size: int +) -> Tuple[int, int]: + """ + Compute top-left (x, y) coordinates for a center crop. + + :param image_width: Width of the source image. + :param image_height: Height of the source image. + :param crop_size: Size of the square crop (width and height). + :return: Tuple of (x, y) for top-left corner of center crop. + :raises ValueError: If crop_size exceeds image dimensions. + """ + if crop_size > image_width or crop_size > image_height: + raise ValueError( + f"crop_size ({crop_size}) exceeds image dimensions " + f"({image_width}x{image_height})." + ) + + x = (image_width - crop_size) // 2 + y = (image_height - crop_size) // 2 + return x, y + + +def generate_center_crops( + dataset: BaseImageDataset, + crop_size: int, +) -> CropMap: + """ + Generate center crop coordinates for each sample in a BaseImageDataset. + + :param dataset: A BaseImageDataset instance (or compatible object with + `file_state.manifest` attribute supporting `get_image_dimensions()`). + :param crop_size: Size of the square crop (same width and height). + :return: Dictionary mapping manifest indices to lists of crop specs. + Format: {manifest_idx: [((x, y), width, height), ...]} + :raises ValueError: If crop_size is non-positive, if no active channels + are configured, or if channel dimensions don't match for any sample. + """ + if crop_size <= 0: + raise ValueError(f"crop_size must be positive, got {crop_size}.") + + active_channels = _get_active_channels(dataset) + if not active_channels: + raise ValueError( + "No active channels configured. Set input_channel_keys and/or " + "target_channel_keys on the dataset before generating crops." + ) + + manifest = dataset.file_state.manifest + crop_specs: Dict[int, List[Tuple[Tuple[int, int], int, int]]] = {} + + for idx in range(len(dataset)): + # Get dimensions for all active channels + dims = manifest.get_image_dimensions(idx, channels=active_channels) + + # Validate all channels have matching dimensions + width, height = _validate_same_dimensions_across_channels( + dims, active_channels, idx + ) + + # Compute center crop coordinates + x, y = _compute_center_crop(width, height, crop_size) + + # Store as crop_specs format: {idx: [((x, y), w, h), ...]} + crop_specs[idx] = [((x, y), crop_size, crop_size)] + + return crop_specs diff --git a/src/virtual_stain_flow/datasets/ds_engine/crop_generators/point_centered.py b/src/virtual_stain_flow/datasets/ds_engine/crop_generators/point_centered.py new file mode 100644 index 0000000..2d82f32 --- /dev/null +++ b/src/virtual_stain_flow/datasets/ds_engine/crop_generators/point_centered.py @@ -0,0 +1,103 @@ +""" +""" + +import numpy as np + +from .protocol import CropMap +from ...base_dataset import BaseImageDataset +from ..ds_utils import ( + _get_active_channels, + _validate_same_dimensions_across_channels +) + + +def _compute_point_centered_crops( + image_width: int, + image_height: int, + crop_size: int, + centers: dict[str, np.ndarray] +) -> list[tuple[int, int]]: + """ + Compute the top-left coordinates of crops centered around specified points, + ensuring that the crops fit fully within the image boundaries. + + :param image_width: Width of the image. + :param image_height: Height of the image. + :param crop_size: Size of the crops to generate. + :param centers: Dictionary containing 'X' and 'Y' coordinates of the centers. + :return: List of (x, y) tuples representing the top-left coordinates of the crops. + """ + + crops = [] + + for x, y in zip(centers['X'], centers['Y']): + + if x <= crop_size // 2 or x >= image_width - crop_size // 2 or \ + y <= crop_size // 2 or y >= image_height - crop_size // 2: + continue # Skip points too close to the edge for a full crop + + crop_x = int(x - crop_size // 2) + crop_y = int(y - crop_size // 2) + crops.append((crop_x, crop_y)) + + return crops + + +def generate_point_centered_crops( + dataset: BaseImageDataset, + crop_size: int | None = None, + mapping: list[dict[str, np.ndarray]] | None = None, +) -> CropMap: + """ + Generate crop specifications centered around specified points + for each image in the dataset. + + :param dataset: BaseImageDataset containing the images and metadata. + :param crop_size: Size of the crops to generate. Made optional + for better error handling when called from the CropImageDataset + .from_base_dataset class method. + :param mapping: List of dictionaries containing the centers for each image. + Each dictionary should have keys 'X' and 'Y' with corresponding numpy + arrays of coordinates. + Made optional for the same reason as crop_size. + :return: CropMap containing the generated crop specifications. + """ + + if crop_size is None: + raise ValueError( + "crop_size must be provided for point-centered crop generation." + ) + if mapping is None: + raise ValueError( + "mapping must be provided for point-centered crop generation." + ) + + if crop_size <= 0: + raise ValueError(f"crop_size must be positive, got {crop_size}.") + + active_channels = _get_active_channels(dataset) + if not active_channels: + raise ValueError( + "No active channels configured. Set input_channel_keys and/or " + "target_channel_keys on the dataset before generating crops." + ) + + manifest = dataset.file_state.manifest + crop_specs: dict[int, list[tuple[tuple[int, int], int, int]]] = {} + + for idx in range(len(dataset)): + + dims = manifest.get_image_dimensions(idx, channels=active_channels) + + width, height = _validate_same_dimensions_across_channels( + dims, active_channels, idx + ) + + centers = mapping[idx] + + crop_lists = _compute_point_centered_crops(width, height, crop_size, centers) + crop_specs[idx] = [ + (crop_coords, crop_size, crop_size) for crop_coords in crop_lists + ] + + return crop_specs diff --git a/src/virtual_stain_flow/datasets/ds_engine/crop_generators/protocol.py b/src/virtual_stain_flow/datasets/ds_engine/crop_generators/protocol.py new file mode 100644 index 0000000..374dc91 --- /dev/null +++ b/src/virtual_stain_flow/datasets/ds_engine/crop_generators/protocol.py @@ -0,0 +1,25 @@ +""" +protocol.py + +Defines the CropGenerator protocol for crop generator functions. +""" + +from typing import Dict, List, Tuple, Any, Protocol + +from ...base_dataset import BaseImageDataset + + +CropSpec = Tuple[Tuple[int, int], int, int] +CropMap = Dict[int, List[CropSpec]] + + +class CropGenerator(Protocol): + """ + Protocol for crop generator functions. + """ + def __call__( + self, + dataset: BaseImageDataset, + **kwargs: Any + ) -> CropMap: + pass From 7ceb8279e94ed304ac573689dc9aaa8dc44c63da Mon Sep 17 00:00:00 2001 From: Weishan Li Date: Wed, 10 Jun 2026 09:35:55 -0600 Subject: [PATCH 2/9] Add new tile crop generator for creating non-overlapping crops tiling the full FOV best effort --- .../ds_engine/crop_generators/__init__.py | 13 ++ .../ds_engine/crop_generators/tile.py | 119 ++++++++++++++++++ 2 files changed, 132 insertions(+) create mode 100644 src/virtual_stain_flow/datasets/ds_engine/crop_generators/__init__.py create mode 100644 src/virtual_stain_flow/datasets/ds_engine/crop_generators/tile.py diff --git a/src/virtual_stain_flow/datasets/ds_engine/crop_generators/__init__.py b/src/virtual_stain_flow/datasets/ds_engine/crop_generators/__init__.py new file mode 100644 index 0000000..a012d18 --- /dev/null +++ b/src/virtual_stain_flow/datasets/ds_engine/crop_generators/__init__.py @@ -0,0 +1,13 @@ +from .protocol import CropMap, CropSpec, CropGenerator +from .center import generate_center_crops +from .point_centered import generate_point_centered_crops +from .tile import generate_tile_crops + +__all__ = [ + "CropMap", + "CropSpec", + "CropGenerator", + "generate_center_crops", + "generate_point_centered_crops", + "generate_tile_crops" +] diff --git a/src/virtual_stain_flow/datasets/ds_engine/crop_generators/tile.py b/src/virtual_stain_flow/datasets/ds_engine/crop_generators/tile.py new file mode 100644 index 0000000..0c37e8d --- /dev/null +++ b/src/virtual_stain_flow/datasets/ds_engine/crop_generators/tile.py @@ -0,0 +1,119 @@ +""" +Crop generator module for creating non-overlapping tile crops centered within +images in a BaseImageDataset. Provides a best-effort tiling approach that +maximizes the number of full tiles while centering the grid within the image +boundaries. +""" + +from typing import List + +from .protocol import CropMap, CropSpec +from ...base_dataset import BaseImageDataset +from ..ds_utils import ( + _get_active_channels, + _validate_same_dimensions_across_channels +) + + +def _compute_centered_tile_positions( + image_extent: int, + crop_size: int +) -> List[int]: + """ + Compute 1D tile start positions for non-overlapping tiles centered + within an image extent. + + :param image_extent: Size of image extent along one axis (width or height). + :param crop_size: Size of one tile along the same axis. + :return: List of start positions for each tile. + :raises ValueError: If crop_size exceeds image extent. + """ + n_tiles = image_extent // crop_size + if n_tiles == 0: + raise ValueError( + f"crop_size ({crop_size}) exceeds image dimensions " + f"along one axis ({image_extent})." + ) + + covered_extent = n_tiles * crop_size + margin = image_extent - covered_extent + start = margin // 2 + + return [start + (tile_idx * crop_size) for tile_idx in range(n_tiles)] + + +def _compute_centered_tile_crops( + image_width: int, + image_height: int, + crop_size: int +) -> List[CropSpec]: + """ + Compute non-overlapping square tiles arranged on a centered grid. + + :param image_width: Width of the source image. + :param image_height: Height of the source image. + :param crop_size: Size of square tile crop. + :return: List of crop specs in format ((x, y), width, height). + :raises ValueError: If crop_size exceeds image width or height. + """ + if crop_size > image_width or crop_size > image_height: + raise ValueError( + f"crop_size ({crop_size}) exceeds image dimensions " + f"({image_width}x{image_height})." + ) + + x_positions = _compute_centered_tile_positions(image_width, crop_size) + y_positions = _compute_centered_tile_positions(image_height, crop_size) + + return [ + ((x, y), crop_size, crop_size) + for y in y_positions + for x in x_positions + ] + + +def generate_tile_crops( + dataset: BaseImageDataset, + crop_size: int, +) -> CropMap: + """ + Generate best-effort centered, non-overlapping tiling crops + for each sample in a BaseImageDataset. + + Tiling is "best-effort" in that it uses as many full, non-overlapping + tiles of size `crop_size` as possible along each axis, then centers + the tile grid within the remaining field of view. + + :param dataset: A BaseImageDataset instance (or compatible object with + `file_state.manifest` attribute supporting `get_image_dimensions()`). + :param crop_size: Size of the square crop tile (same width and height). + :return: Dictionary mapping manifest indices to lists of crop specs. + Format: {manifest_idx: [((x, y), width, height), ...]} + :raises ValueError: If crop_size is non-positive, if no active channels + are configured, or if channel dimensions don't match for any sample. + """ + if crop_size <= 0: + raise ValueError(f"crop_size must be positive, got {crop_size}.") + + active_channels = _get_active_channels(dataset) + if not active_channels: + raise ValueError( + "No active channels configured. Set input_channel_keys and/or " + "target_channel_keys on the dataset before generating crops." + ) + + manifest = dataset.file_state.manifest + crop_specs: CropMap = {} + + for idx in range(len(dataset)): + dims = manifest.get_image_dimensions(idx, channels=active_channels) + + width, height = _validate_same_dimensions_across_channels( + dims, active_channels, idx + ) + + crop_specs[idx] = _compute_centered_tile_crops( + width, height, crop_size + ) + + return crop_specs From b5b58ce77b260a177e5b275fb54afd5f64396e74 Mon Sep 17 00:00:00 2001 From: Weishan Li Date: Wed, 10 Jun 2026 09:37:43 -0600 Subject: [PATCH 3/9] Refactor old crop_generator.py by removing functionalities moved to subpackage while supporting older import signatures for compatibility --- .../datasets/ds_engine/crop_generator.py | 107 +++--------------- 1 file changed, 14 insertions(+), 93 deletions(-) diff --git a/src/virtual_stain_flow/datasets/ds_engine/crop_generator.py b/src/virtual_stain_flow/datasets/ds_engine/crop_generator.py index ad5cad1..bec4f5f 100644 --- a/src/virtual_stain_flow/datasets/ds_engine/crop_generator.py +++ b/src/virtual_stain_flow/datasets/ds_engine/crop_generator.py @@ -3,98 +3,19 @@ Utilities for generating crop coordinates from BaseImageDataset objects. Designed for easy creation of CropImageDataset instances. +Made Facade to account for increased complexity and future expansion. """ -from typing import Dict, List, Tuple, Any, Protocol - -from ..base_dataset import BaseImageDataset -from .ds_utils import ( - _get_active_channels, - _validate_same_dimensions_across_channels -) - -CropSpec = Tuple[Tuple[int, int], int, int] -CropMap = Dict[int, List[CropSpec]] - - -class CropGenerator(Protocol): - """ - Protocol for crop generator functions. - """ - def __call__( - self, - dataset: BaseImageDataset, - **kwargs: Any - ) -> CropMap: - pass - - -def _compute_center_crop( - image_width: int, - image_height: int, - crop_size: int -) -> Tuple[int, int]: - """ - Compute top-left (x, y) coordinates for a center crop. - - :param image_width: Width of the source image. - :param image_height: Height of the source image. - :param crop_size: Size of the square crop (width and height). - :return: Tuple of (x, y) for top-left corner of center crop. - :raises ValueError: If crop_size exceeds image dimensions. - """ - if crop_size > image_width or crop_size > image_height: - raise ValueError( - f"crop_size ({crop_size}) exceeds image dimensions " - f"({image_width}x{image_height})." - ) - - x = (image_width - crop_size) // 2 - y = (image_height - crop_size) // 2 - return x, y - - -def generate_center_crops( - dataset: BaseImageDataset, - crop_size: int, -) -> CropMap: - """ - Generate center crop coordinates for each sample in a BaseImageDataset. - - :param dataset: A BaseImageDataset instance (or compatible object with - `file_state.manifest` attribute supporting `get_image_dimensions()`). - :param crop_size: Size of the square crop (same width and height). - :return: Dictionary mapping manifest indices to lists of crop specs. - Format: {manifest_idx: [((x, y), width, height), ...]} - :raises ValueError: If crop_size is non-positive, if no active channels - are configured, or if channel dimensions don't match for any sample. - """ - if crop_size <= 0: - raise ValueError(f"crop_size must be positive, got {crop_size}.") - - active_channels = _get_active_channels(dataset) - if not active_channels: - raise ValueError( - "No active channels configured. Set input_channel_keys and/or " - "target_channel_keys on the dataset before generating crops." - ) - - manifest = dataset.file_state.manifest - crop_specs: Dict[int, List[Tuple[Tuple[int, int], int, int]]] = {} - - for idx in range(len(dataset)): - # Get dimensions for all active channels - dims = manifest.get_image_dimensions(idx, channels=active_channels) - - # Validate all channels have matching dimensions - width, height = _validate_same_dimensions_across_channels( - dims, active_channels, idx - ) - - # Compute center crop coordinates - x, y = _compute_center_crop(width, height, crop_size) - - # Store as crop_specs format: {idx: [((x, y), w, h), ...]} - crop_specs[idx] = [((x, y), crop_size, crop_size)] - - return crop_specs +from .crop_generators.protocol import CropSpec, CropMap, CropGenerator +from .crop_generators.center import generate_center_crops +from .crop_generators.point_centered import generate_point_centered_crops +from .crop_generators.tile import generate_tile_crops + +__all__ = [ + "CropSpec", + "CropMap", + "CropGenerator", + "generate_center_crops", + "generate_point_centered_crops", + "generate_tile_crops", +] From b0f4416a8d567ac91c34c0ed0eb6528affabb7c4 Mon Sep 17 00:00:00 2001 From: Weishan Li Date: Tue, 21 Jul 2026 14:52:17 -0600 Subject: [PATCH 4/9] Enhance crop generation modules with optionally displayed statistics and summary reporting for center, point-centered, and tile crops. --- .../ds_engine/crop_generators/center.py | 24 ++++- .../ds_engine/crop_generators/crop_summary.py | 91 +++++++++++++++++++ .../crop_generators/point_centered.py | 88 +++++++++++++++++- .../ds_engine/crop_generators/tile.py | 30 +++++- 4 files changed, 226 insertions(+), 7 deletions(-) create mode 100644 src/virtual_stain_flow/datasets/ds_engine/crop_generators/crop_summary.py diff --git a/src/virtual_stain_flow/datasets/ds_engine/crop_generators/center.py b/src/virtual_stain_flow/datasets/ds_engine/crop_generators/center.py index 7831847..9ffb8dc 100644 --- a/src/virtual_stain_flow/datasets/ds_engine/crop_generators/center.py +++ b/src/virtual_stain_flow/datasets/ds_engine/crop_generators/center.py @@ -1,8 +1,12 @@ """ +center.py + +Helper module for generating crops centered at FOV """ from typing import Dict, List, Tuple +from .crop_summary import warn_formatted_crop_summary from .protocol import CropMap from ...base_dataset import BaseImageDataset from ..ds_utils import ( @@ -39,6 +43,7 @@ def _compute_center_crop( def generate_center_crops( dataset: BaseImageDataset, crop_size: int, + verbose: bool = False, ) -> CropMap: """ Generate center crop coordinates for each sample in a BaseImageDataset. @@ -46,6 +51,7 @@ def generate_center_crops( :param dataset: A BaseImageDataset instance (or compatible object with `file_state.manifest` attribute supporting `get_image_dimensions()`). :param crop_size: Size of the square crop (same width and height). + :param verbose: If True, emit summary statistics for crop generation. :return: Dictionary mapping manifest indices to lists of crop specs. Format: {manifest_idx: [((x, y), width, height), ...]} :raises ValueError: If crop_size is non-positive, if no active channels @@ -63,8 +69,9 @@ def generate_center_crops( manifest = dataset.file_state.manifest crop_specs: Dict[int, List[Tuple[Tuple[int, int], int, int]]] = {} + total_samples = len(dataset) - for idx in range(len(dataset)): + for idx in range(total_samples): # Get dimensions for all active channels dims = manifest.get_image_dimensions(idx, channels=active_channels) @@ -78,5 +85,20 @@ def generate_center_crops( # Store as crop_specs format: {idx: [((x, y), w, h), ...]} crop_specs[idx] = [((x, y), crop_size, crop_size)] + + if verbose: + successful_center_crops = len(crop_specs) + warn_formatted_crop_summary( + title="Center crop generation statistics:", + detail_line=( + "Generation criterion: exactly one centered crop is generated " + "per sample when crop size fits within image bounds." + ), + metrics={ + "Success count / total dataset count": ( + f"{successful_center_crops}/{total_samples}" + ), + }, + ) return crop_specs diff --git a/src/virtual_stain_flow/datasets/ds_engine/crop_generators/crop_summary.py b/src/virtual_stain_flow/datasets/ds_engine/crop_generators/crop_summary.py new file mode 100644 index 0000000..074d4f8 --- /dev/null +++ b/src/virtual_stain_flow/datasets/ds_engine/crop_generators/crop_summary.py @@ -0,0 +1,91 @@ +""" +crop_summary.py + +Helper module for formatting crop generation summaries, +shared by crop generating modules +""" + +from collections.abc import Mapping +import warnings + + +class CropSummaryWarning(UserWarning): + """Warning category for crop generation summary reports.""" + + +def _stringify_metric_value(value: object) -> str: + """Convert metric values to compact, human-readable strings.""" + if isinstance(value, float): + return f"{value:.2f}" + return str(value) + + +def build_stats_table(metrics: Mapping[str, object]) -> str: + """ + Build a simple ASCII table from metric-value key-value pairs. + + :param metrics: Ordered metric mapping of label -> value. + :return: Multi-line ASCII table. + """ + metric_header = "Metric" + value_header = "Value" + + rows = [(metric, _stringify_metric_value(value)) for metric, value in metrics.items()] + if not rows: + rows = [("No metrics", "n/a")] + + metric_width = max(len(metric_header), *(len(metric) for metric, _ in rows)) + value_width = max(len(value_header), *(len(value) for _, value in rows)) + + separator = f"+-{'-' * metric_width}-+-{'-' * value_width}-+" + header = f"| {metric_header:<{metric_width}} | {value_header:<{value_width}} |" + body = [ + f"| {metric:<{metric_width}} | {value:>{value_width}} |" + for metric, value in rows + ] + + return "\n".join([separator, header, separator, *body, separator]) + + +def format_crop_summary( + title: str, + metrics: Mapping[str, object], + detail_line: str | None = None, +) -> str: + """ + Format a crop summary title, optional detail line, and metric table. + + :param title: Summary title line. + :param metrics: Ordered metric mapping of label -> value. + :param detail_line: Optional descriptive one-liner for context. + :return: Formatted summary text. + """ + stats_table = build_stats_table(metrics) + if detail_line: + return f"{title}\n{detail_line}\n{stats_table}" + return f"{title}\n{stats_table}" + + +def warn_formatted_crop_summary( + title: str, + metrics: Mapping[str, object], + detail_line: str | None = None, +) -> None: + """ + Emit a module-scoped crop summary warning without file/line prefixes. + + :param title: Summary title line. + :param metrics: Ordered metric mapping of label -> value. + :param detail_line: Optional descriptive one-liner for context. + """ + message = format_crop_summary(title=title, metrics=metrics, detail_line=detail_line) + + original_formatwarning = warnings.formatwarning + try: + warnings.formatwarning = ( + lambda msg, category, filename, lineno, line=None: + f"{category.__name__}: {msg}\n" + ) + warnings.warn(message, category=CropSummaryWarning, stacklevel=2) + finally: + warnings.formatwarning = original_formatwarning diff --git a/src/virtual_stain_flow/datasets/ds_engine/crop_generators/point_centered.py b/src/virtual_stain_flow/datasets/ds_engine/crop_generators/point_centered.py index 2d82f32..4ec047e 100644 --- a/src/virtual_stain_flow/datasets/ds_engine/crop_generators/point_centered.py +++ b/src/virtual_stain_flow/datasets/ds_engine/crop_generators/point_centered.py @@ -1,8 +1,12 @@ """ +point_centered.py + +Helper module for generating crops centered around specified points within an image. """ import numpy as np +from .crop_summary import warn_formatted_crop_summary from .protocol import CropMap from ...base_dataset import BaseImageDataset from ..ds_utils import ( @@ -15,8 +19,9 @@ def _compute_point_centered_crops( image_width: int, image_height: int, crop_size: int, - centers: dict[str, np.ndarray] -) -> list[tuple[int, int]]: + centers: dict[str, np.ndarray], + track_skipped: bool = False, +) -> tuple[list[tuple[int, int]], int, int]: """ Compute the top-left coordinates of crops centered around specified points, ensuring that the crops fit fully within the image boundaries. @@ -25,28 +30,39 @@ def _compute_point_centered_crops( :param image_height: Height of the image. :param crop_size: Size of the crops to generate. :param centers: Dictionary containing 'X' and 'Y' coordinates of the centers. - :return: List of (x, y) tuples representing the top-left coordinates of the crops. + :param track_skipped: Whether to count points skipped due to boundary checks. + :return: Tuple of: + - List of (x, y) tuples representing the top-left coordinates of the crops. + - Number of skipped points. + - Number of accepted points used for crops. """ crops = [] + skipped_count = 0 + accepted_count = 0 for x, y in zip(centers['X'], centers['Y']): if x <= crop_size // 2 or x >= image_width - crop_size // 2 or \ y <= crop_size // 2 or y >= image_height - crop_size // 2: + if track_skipped: + skipped_count += 1 continue # Skip points too close to the edge for a full crop crop_x = int(x - crop_size // 2) crop_y = int(y - crop_size // 2) crops.append((crop_x, crop_y)) + if track_skipped: + accepted_count += 1 - return crops + return crops, skipped_count, accepted_count def generate_point_centered_crops( dataset: BaseImageDataset, crop_size: int | None = None, mapping: list[dict[str, np.ndarray]] | None = None, + verbose: bool = False, ) -> CropMap: """ Generate crop specifications centered around specified points @@ -60,6 +76,7 @@ def generate_point_centered_crops( Each dictionary should have keys 'X' and 'Y' with corresponding numpy arrays of coordinates. Made optional for the same reason as crop_size. + :param verbose: If True, compute and report skipped-point statistics. :return: CropMap containing the generated crop specifications. """ @@ -84,6 +101,8 @@ def generate_point_centered_crops( manifest = dataset.file_state.manifest crop_specs: dict[int, list[tuple[tuple[int, int], int, int]]] = {} + skipped_points_per_image: list[int] = [] + accepted_points_per_image: list[int] = [] for idx in range(len(dataset)): @@ -95,9 +114,68 @@ def generate_point_centered_crops( centers = mapping[idx] - crop_lists = _compute_point_centered_crops(width, height, crop_size, centers) + crop_lists, skipped_count, accepted_count = _compute_point_centered_crops( + width, + height, + crop_size, + centers, + track_skipped=verbose, + ) crop_specs[idx] = [ (crop_coords, crop_size, crop_size) for crop_coords in crop_lists ] + if verbose: + skipped_points_per_image.append(skipped_count) + accepted_points_per_image.append(accepted_count) + + if verbose: + total_points_skipped = sum(skipped_points_per_image) + total_points_accepted = sum(accepted_points_per_image) + num_images = len(skipped_points_per_image) + + avg_accepted_per_image = ( + total_points_accepted / num_images if num_images > 0 else 0.0 + ) + images_with_accepted = sum(1 for count in accepted_points_per_image if count > 0) + avg_accepted_per_affected_image = ( + total_points_accepted / images_with_accepted + if images_with_accepted > 0 + else 0.0 + ) + max_accepted_single_image = max(accepted_points_per_image, default=0) + + if total_points_skipped > 0: + avg_skipped_per_image = ( + total_points_skipped / num_images if num_images > 0 else 0.0 + ) + images_with_skips = sum(1 for count in skipped_points_per_image if count > 0) + avg_skipped_per_affected_image = ( + total_points_skipped / images_with_skips + if images_with_skips > 0 + else 0.0 + ) + max_skipped_single_image = max(skipped_points_per_image, default=0) + + warn_formatted_crop_summary( + title="Point-centered crop generation statistics:", + detail_line=( + "Exclusion criterion: a point is rejected when its center " + "is within or on half a crop-size from any image border, " + "which prevents full in-bounds crop extraction." + ), + metrics={ + "Total accepted": total_points_accepted, + "Total rejected": total_points_skipped, + "Mean accepted / image": avg_accepted_per_image, + "Mean rejected / image": avg_skipped_per_image, + "Mean accepted / affected image": avg_accepted_per_affected_image, + "Mean rejected / affected image": avg_skipped_per_affected_image, + "Images with accepted": f"{images_with_accepted}/{num_images}", + "Images with rejected": f"{images_with_skips}/{num_images}", + "Max accepted in one image": max_accepted_single_image, + "Max rejected in one image": max_skipped_single_image, + }, + ) + return crop_specs diff --git a/src/virtual_stain_flow/datasets/ds_engine/crop_generators/tile.py b/src/virtual_stain_flow/datasets/ds_engine/crop_generators/tile.py index 0c37e8d..80581e6 100644 --- a/src/virtual_stain_flow/datasets/ds_engine/crop_generators/tile.py +++ b/src/virtual_stain_flow/datasets/ds_engine/crop_generators/tile.py @@ -1,4 +1,6 @@ """ +tile.py + Crop generator module for creating non-overlapping tile crops centered within images in a BaseImageDataset. Provides a best-effort tiling approach that maximizes the number of full tiles while centering the grid within the image @@ -7,6 +9,7 @@ from typing import List +from .crop_summary import warn_formatted_crop_summary from .protocol import CropMap, CropSpec from ...base_dataset import BaseImageDataset from ..ds_utils import ( @@ -75,6 +78,7 @@ def _compute_centered_tile_crops( def generate_tile_crops( dataset: BaseImageDataset, crop_size: int, + verbose: bool = False, ) -> CropMap: """ Generate best-effort centered, non-overlapping tiling crops @@ -87,6 +91,7 @@ def generate_tile_crops( :param dataset: A BaseImageDataset instance (or compatible object with `file_state.manifest` attribute supporting `get_image_dimensions()`). :param crop_size: Size of the square crop tile (same width and height). + :param verbose: If True, emit summary statistics for crop generation. :return: Dictionary mapping manifest indices to lists of crop specs. Format: {manifest_idx: [((x, y), width, height), ...]} :raises ValueError: If crop_size is non-positive, if no active channels @@ -104,8 +109,10 @@ def generate_tile_crops( manifest = dataset.file_state.manifest crop_specs: CropMap = {} + tile_count_distribution: dict[int, int] = {} + total_samples = len(dataset) - for idx in range(len(dataset)): + for idx in range(total_samples): dims = manifest.get_image_dimensions(idx, channels=active_channels) width, height = _validate_same_dimensions_across_channels( @@ -115,5 +122,26 @@ def generate_tile_crops( crop_specs[idx] = _compute_centered_tile_crops( width, height, crop_size ) + tile_count = len(crop_specs[idx]) + tile_count_distribution[tile_count] = ( + tile_count_distribution.get(tile_count, 0) + 1 + ) + + if verbose: + distribution_metrics = { + f"Tiles per FOV = {tile_count}": full_fov_count + for tile_count, full_fov_count in sorted(tile_count_distribution.items()) + } + warn_formatted_crop_summary( + title="Tile crop generation statistics:", + detail_line=( + "Tiling criterion: each FOV receives as many full, non-overlapping " + "tiles as fit along width and height, centered within remaining margins." + ), + metrics={ + "Total dataset count": total_samples, + **distribution_metrics, + }, + ) return crop_specs From 0a5ac3691cae3db57ea8c4c6e48f135a31848d1b Mon Sep 17 00:00:00 2001 From: Weishan Li Date: Tue, 21 Jul 2026 14:56:28 -0600 Subject: [PATCH 5/9] Add validation for crop_size parameter in generate_point_centered_crops function --- .../datasets/ds_engine/crop_generators/point_centered.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/virtual_stain_flow/datasets/ds_engine/crop_generators/point_centered.py b/src/virtual_stain_flow/datasets/ds_engine/crop_generators/point_centered.py index 4ec047e..a7769ab 100644 --- a/src/virtual_stain_flow/datasets/ds_engine/crop_generators/point_centered.py +++ b/src/virtual_stain_flow/datasets/ds_engine/crop_generators/point_centered.py @@ -89,6 +89,8 @@ def generate_point_centered_crops( "mapping must be provided for point-centered crop generation." ) + if not isinstance(crop_size, int): + raise ValueError(f"crop_size must be an integer, got {type(crop_size).__name__}.") if crop_size <= 0: raise ValueError(f"crop_size must be positive, got {crop_size}.") From 343138c5003fcc3fb193d862b158141ec48e6d07 Mon Sep 17 00:00:00 2001 From: Weishan Li Date: Tue, 21 Jul 2026 15:24:59 -0600 Subject: [PATCH 6/9] Enhance module documentation for tile crop generator with additional explanation on non-overlapping tile benefits for convolutional neural networks. --- .../datasets/ds_engine/crop_generators/tile.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/virtual_stain_flow/datasets/ds_engine/crop_generators/tile.py b/src/virtual_stain_flow/datasets/ds_engine/crop_generators/tile.py index 80581e6..1e20703 100644 --- a/src/virtual_stain_flow/datasets/ds_engine/crop_generators/tile.py +++ b/src/virtual_stain_flow/datasets/ds_engine/crop_generators/tile.py @@ -5,6 +5,12 @@ images in a BaseImageDataset. Provides a best-effort tiling approach that maximizes the number of full tiles while centering the grid within the image boundaries. + +The non-overlapping decision is motivated by the property of convolutional +neural networks being translation-invariant, that it learns the same +information from a blob of pixels whether it is located at the center, the +left/right/top/down edge/corner. Having non-overlapping tiles ensures +minimal redundancy in the information captured across tiles. """ from typing import List From 589bac9fa83e12c7e4ee1bd2bbc94d094c39e5d6 Mon Sep 17 00:00:00 2001 From: Weishan Li Date: Tue, 21 Jul 2026 15:36:35 -0600 Subject: [PATCH 7/9] Fix type hint for 'how' parameter in CropImageDataset class to use CropGenerator type directly --- src/virtual_stain_flow/datasets/crop_dataset.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/virtual_stain_flow/datasets/crop_dataset.py b/src/virtual_stain_flow/datasets/crop_dataset.py index 975019f..0d0ff20 100644 --- a/src/virtual_stain_flow/datasets/crop_dataset.py +++ b/src/virtual_stain_flow/datasets/crop_dataset.py @@ -2,7 +2,7 @@ crop_dataset.py """ -from typing import Any, Dict, List, Sequence, Optional, Tuple, Union, Type +from typing import Any, Dict, List, Sequence, Optional, Tuple, Union import numpy as np import pandas as pd @@ -157,7 +157,7 @@ def from_base_dataset( cls, base_dataset: BaseImageDataset, transforms: Optional[Sequence[LoggableTransform]] = None, - how: Type[CropGenerator] = generate_center_crops, + how: CropGenerator = generate_center_crops, **kwargs: Any ) -> 'CropImageDataset': """ From df6209638e0166525fcf24c56720960144b8d81c Mon Sep 17 00:00:00 2001 From: Weishan Li Date: Tue, 21 Jul 2026 15:45:57 -0600 Subject: [PATCH 8/9] Expose helper methods to facilitate testing. --- .../datasets/ds_engine/crop_generator.py | 7 +++++-- .../datasets/ds_engine/crop_generators/__init__.py | 5 +++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/virtual_stain_flow/datasets/ds_engine/crop_generator.py b/src/virtual_stain_flow/datasets/ds_engine/crop_generator.py index bec4f5f..406aca8 100644 --- a/src/virtual_stain_flow/datasets/ds_engine/crop_generator.py +++ b/src/virtual_stain_flow/datasets/ds_engine/crop_generator.py @@ -7,10 +7,12 @@ """ from .crop_generators.protocol import CropSpec, CropMap, CropGenerator -from .crop_generators.center import generate_center_crops +from .crop_generators.center import ( + generate_center_crops, + _compute_center_crop, +) from .crop_generators.point_centered import generate_point_centered_crops from .crop_generators.tile import generate_tile_crops - __all__ = [ "CropSpec", "CropMap", @@ -18,4 +20,5 @@ "generate_center_crops", "generate_point_centered_crops", "generate_tile_crops", + "_compute_center_crop", ] diff --git a/src/virtual_stain_flow/datasets/ds_engine/crop_generators/__init__.py b/src/virtual_stain_flow/datasets/ds_engine/crop_generators/__init__.py index a012d18..5ff9463 100644 --- a/src/virtual_stain_flow/datasets/ds_engine/crop_generators/__init__.py +++ b/src/virtual_stain_flow/datasets/ds_engine/crop_generators/__init__.py @@ -1,5 +1,5 @@ from .protocol import CropMap, CropSpec, CropGenerator -from .center import generate_center_crops +from .center import generate_center_crops, _compute_center_crop from .point_centered import generate_point_centered_crops from .tile import generate_tile_crops @@ -9,5 +9,6 @@ "CropGenerator", "generate_center_crops", "generate_point_centered_crops", - "generate_tile_crops" + "generate_tile_crops", + "_compute_center_crop" ] From 07ae4f9d94c282737b2d36b0e0496b71b31312b9 Mon Sep 17 00:00:00 2001 From: Weishan Li Date: Tue, 21 Jul 2026 15:56:39 -0600 Subject: [PATCH 9/9] Add unit tests for crop summary and point-centered crop generator functionality --- .../datasets/ds_engine/test_crop_generator.py | 11 ++ tests/datasets/ds_engine/test_crop_summary.py | 48 ++++++ .../test_point_centered_crop_generator.py | 146 ++++++++++++++++++ .../ds_engine/test_tile_crop_generator.py | 116 ++++++++++++++ 4 files changed, 321 insertions(+) create mode 100644 tests/datasets/ds_engine/test_crop_summary.py create mode 100644 tests/datasets/ds_engine/test_point_centered_crop_generator.py create mode 100644 tests/datasets/ds_engine/test_tile_crop_generator.py diff --git a/tests/datasets/ds_engine/test_crop_generator.py b/tests/datasets/ds_engine/test_crop_generator.py index 3f23327..e9e87f0 100644 --- a/tests/datasets/ds_engine/test_crop_generator.py +++ b/tests/datasets/ds_engine/test_crop_generator.py @@ -9,6 +9,9 @@ _compute_center_crop, generate_center_crops, ) +from virtual_stain_flow.datasets.ds_engine.crop_generators.crop_summary import ( + CropSummaryWarning, +) class TestComputeCenterCrop: @@ -87,3 +90,11 @@ def test_raises_error_when_crop_too_large(self, basic_dataset): # Images are 10x10, so crop_size=20 should fail with pytest.raises(ValueError, match="exceeds image dimensions"): generate_center_crops(basic_dataset, crop_size=20) + + def test_verbose_emits_summary_warning(self, basic_dataset): + """Should emit one summary warning in verbose mode.""" + with pytest.warns(CropSummaryWarning, match="Center crop generation statistics") as record: + generate_center_crops(basic_dataset, crop_size=4, verbose=True) + + message = str(record[0].message) + assert "Success count / total dataset count" in message diff --git a/tests/datasets/ds_engine/test_crop_summary.py b/tests/datasets/ds_engine/test_crop_summary.py new file mode 100644 index 0000000..244f195 --- /dev/null +++ b/tests/datasets/ds_engine/test_crop_summary.py @@ -0,0 +1,48 @@ +""" +Minimal tests for crop summary formatting helpers. +""" + +import pytest + +from virtual_stain_flow.datasets.ds_engine.crop_generators.crop_summary import ( + CropSummaryWarning, + build_stats_table, + format_crop_summary, + warn_formatted_crop_summary, +) + + +def test_build_stats_table_renders_headers_and_rows(): + """Should render a compact ASCII table from metric key-value pairs.""" + table = build_stats_table({"Total accepted": 12, "Total rejected": 3}) + + assert "Metric" in table + assert "Value" in table + assert "Total accepted" in table + assert "Total rejected" in table + + +def test_warn_formatted_crop_summary_emits_warning_with_formatted_body(): + """Should emit CropSummaryWarning containing title, detail, and table rows.""" + with pytest.warns(CropSummaryWarning, match="Example summary") as record: + warn_formatted_crop_summary( + title="Example summary", + detail_line="Example criterion line.", + metrics={"Total": 2, "Mean": 1.0}, + ) + + message = str(record[0].message) + assert "Example criterion line." in message + assert "Total" in message + assert "Mean" in message + + +def test_format_crop_summary_without_detail_still_includes_table(): + """Should format title and table when detail line is omitted.""" + summary = format_crop_summary( + title="No detail summary", + metrics={"Success": "3/3"}, + ) + + assert "No detail summary" in summary + assert "Success" in summary diff --git a/tests/datasets/ds_engine/test_point_centered_crop_generator.py b/tests/datasets/ds_engine/test_point_centered_crop_generator.py new file mode 100644 index 0000000..95eee01 --- /dev/null +++ b/tests/datasets/ds_engine/test_point_centered_crop_generator.py @@ -0,0 +1,146 @@ +""" +Tests for point-centered crop generator core functionality. +""" + +import warnings + +import numpy as np +import pytest + +from virtual_stain_flow.datasets.base_dataset import BaseImageDataset +from virtual_stain_flow.datasets.ds_engine.crop_generator import ( + generate_point_centered_crops, +) +from virtual_stain_flow.datasets.ds_engine.crop_generators.crop_summary import ( + CropSummaryWarning, +) +from virtual_stain_flow.datasets.ds_engine.crop_generators.point_centered import ( + _compute_point_centered_crops, +) + + +class TestComputePointCenteredCrops: + """Core tests for point-centered crop coordinate computation.""" + + def test_computes_crops_and_tracks_counts(self): + """Should return valid crop coordinates with accepted/rejected counts.""" + centers = { + "X": np.array([5, 2, 8, 7]), + "Y": np.array([5, 5, 8, 7]), + } + + crops, skipped, accepted = _compute_point_centered_crops( + image_width=10, + image_height=10, + crop_size=4, + centers=centers, + track_skipped=True, + ) + + assert crops == [(3, 3), (5, 5)] + assert skipped == 2 + assert accepted == 2 + + +class TestGeneratePointCenteredCrops: + """Core tests for generate_point_centered_crops.""" + + def test_generates_expected_crop_specs(self, basic_dataset): + """Should generate crop specs in expected format for each sample.""" + mapping = [ + {"X": np.array([5, 7]), "Y": np.array([5, 7])}, + {"X": np.array([6]), "Y": np.array([6])}, + {"X": np.array([5, 2]), "Y": np.array([5, 5])}, + ] + + crop_specs = generate_point_centered_crops( + dataset=basic_dataset, + crop_size=4, + mapping=mapping, + ) + + assert set(crop_specs.keys()) == {0, 1, 2} + assert crop_specs[0] == [((3, 3), 4, 4), ((5, 5), 4, 4)] + assert crop_specs[1] == [((4, 4), 4, 4)] + assert crop_specs[2] == [((3, 3), 4, 4)] + + def test_verbose_emits_summary_warning_when_points_rejected(self, basic_dataset): + """Should emit one summary warning when any points are rejected.""" + mapping = [ + {"X": np.array([5, 2]), "Y": np.array([5, 5])}, + {"X": np.array([6]), "Y": np.array([6])}, + {"X": np.array([7]), "Y": np.array([7])}, + ] + + with pytest.warns( + CropSummaryWarning, + match="Point-centered crop generation statistics", + ) as record: + generate_point_centered_crops( + dataset=basic_dataset, + crop_size=4, + mapping=mapping, + verbose=True, + ) + + message = str(record[0].message) + assert "Total accepted" in message + assert "Total rejected" in message + + def test_verbose_no_warning_when_no_points_rejected(self, basic_dataset): + """Should not emit summary warning when no points are rejected.""" + mapping = [ + {"X": np.array([5, 7]), "Y": np.array([5, 7])}, + {"X": np.array([6]), "Y": np.array([6])}, + {"X": np.array([7]), "Y": np.array([7])}, + ] + + with warnings.catch_warnings(record=True) as record: + warnings.simplefilter("always") + generate_point_centered_crops( + dataset=basic_dataset, + crop_size=4, + mapping=mapping, + verbose=True, + ) + + assert len(record) == 0 + + @pytest.mark.parametrize( + "crop_size,mapping,match", + [ + (None, [{"X": np.array([5]), "Y": np.array([5])}], "crop_size must be provided"), + (4, None, "mapping must be provided"), + (1.5, [{"X": np.array([5]), "Y": np.array([5])}], "crop_size must be an integer"), + (0, [{"X": np.array([5]), "Y": np.array([5])}], "crop_size must be positive"), + ], + ) + def test_validates_required_arguments(self, basic_dataset, crop_size, mapping, match): + """Should validate required inputs and crop_size constraints.""" + with pytest.raises(ValueError, match=match): + generate_point_centered_crops( + dataset=basic_dataset, + crop_size=crop_size, + mapping=mapping, + ) + + def test_raises_error_when_no_active_channels(self, file_index): + """Should raise ValueError when no channels are configured.""" + dataset = BaseImageDataset( + file_index=file_index, + pil_image_mode="I;16", + input_channel_keys=None, + target_channel_keys=None, + ) + mapping = [ + {"X": np.array([5]), "Y": np.array([5])}, + {"X": np.array([5]), "Y": np.array([5])}, + {"X": np.array([5]), "Y": np.array([5])}, + ] + + with pytest.raises(ValueError, match="No active channels"): + generate_point_centered_crops( + dataset=dataset, + crop_size=4, + mapping=mapping, + ) diff --git a/tests/datasets/ds_engine/test_tile_crop_generator.py b/tests/datasets/ds_engine/test_tile_crop_generator.py new file mode 100644 index 0000000..2785534 --- /dev/null +++ b/tests/datasets/ds_engine/test_tile_crop_generator.py @@ -0,0 +1,116 @@ +""" +Tests for tile crop generator functions. +""" + +import pytest + +from virtual_stain_flow.datasets.base_dataset import BaseImageDataset +from virtual_stain_flow.datasets.ds_engine.crop_generator import generate_tile_crops +from virtual_stain_flow.datasets.ds_engine.crop_generators.crop_summary import ( + CropSummaryWarning, +) +from virtual_stain_flow.datasets.ds_engine.crop_generators.tile import ( + _compute_centered_tile_positions, + _compute_centered_tile_crops, +) + + +class TestComputeCenteredTilePositions: + """Tests for 1D centered tile position computation.""" + + def test_returns_single_centered_position_when_one_tile_fits(self): + """Should center one tile when only one fits in the extent.""" + positions = _compute_centered_tile_positions(10, 8) + assert positions == [1] + + def test_returns_evenly_spaced_non_overlapping_positions(self): + """Should produce step=crop_size positions with centered margin.""" + positions = _compute_centered_tile_positions(10, 4) + assert positions == [1, 5] + + def test_returns_zero_margin_positions_when_exact_multiple(self): + """Should start at zero when extent is an exact multiple.""" + positions = _compute_centered_tile_positions(12, 4) + assert positions == [0, 4, 8] + + def test_raises_error_when_crop_larger_than_extent(self): + """Should raise ValueError when no tile can fit.""" + with pytest.raises(ValueError, match="exceeds image dimensions"): + _compute_centered_tile_positions(3, 4) + + +class TestComputeCenteredTileCrops: + """Tests for 2D centered tile crop computation.""" + + def test_computes_centered_non_overlapping_grid(self): + """Should produce Cartesian product of centered x/y positions.""" + crops = _compute_centered_tile_crops(10, 10, 4) + assert crops == [ + ((1, 1), 4, 4), + ((5, 1), 4, 4), + ((1, 5), 4, 4), + ((5, 5), 4, 4), + ] + + def test_raises_error_when_crop_larger_than_image(self): + """Should fail when crop_size exceeds width or height.""" + with pytest.raises(ValueError, match="exceeds image dimensions"): + _compute_centered_tile_crops(3, 10, 4) + + +class TestGenerateTileCrops: + """Tests for generate_tile_crops function.""" + + def test_generates_tiled_crops_for_all_samples(self, basic_dataset): + """Should generate tile crops for each sample index.""" + crop_specs = generate_tile_crops(basic_dataset, crop_size=4) + + assert len(crop_specs) == 3 + assert set(crop_specs.keys()) == {0, 1, 2} + + def test_crop_specs_have_centered_non_overlapping_layout(self, basic_dataset): + """Should return centered non-overlapping 4x4 tiles for 10x10 images.""" + crop_specs = generate_tile_crops(basic_dataset, crop_size=4) + + expected = [ + ((1, 1), 4, 4), + ((5, 1), 4, 4), + ((1, 5), 4, 4), + ((5, 5), 4, 4), + ] + + for _, crops in crop_specs.items(): + assert crops == expected + + def test_raises_error_for_non_positive_crop_size(self, basic_dataset): + """Should raise ValueError for crop_size <= 0.""" + with pytest.raises(ValueError, match="crop_size must be positive"): + generate_tile_crops(basic_dataset, crop_size=0) + + with pytest.raises(ValueError, match="crop_size must be positive"): + generate_tile_crops(basic_dataset, crop_size=-1) + + def test_raises_error_when_no_active_channels(self, file_index): + """Should raise ValueError when no channels are configured.""" + dataset = BaseImageDataset( + file_index=file_index, + pil_image_mode="I;16", + input_channel_keys=None, + target_channel_keys=None, + ) + + with pytest.raises(ValueError, match="No active channels"): + generate_tile_crops(dataset, crop_size=4) + + def test_raises_error_when_crop_too_large(self, basic_dataset): + """Should raise ValueError when crop_size exceeds image dimensions.""" + with pytest.raises(ValueError, match="exceeds image dimensions"): + generate_tile_crops(basic_dataset, crop_size=20) + + def test_verbose_emits_tile_distribution_summary(self, basic_dataset): + """Should emit one summary warning with tile-count distribution.""" + with pytest.warns(CropSummaryWarning, match="Tile crop generation statistics") as record: + generate_tile_crops(basic_dataset, crop_size=4, verbose=True) + + message = str(record[0].message) + assert "Tiles per FOV = 4" in message