diff --git a/REGISTRY.md b/REGISTRY.md index ab9febd..09c9e47 100644 --- a/REGISTRY.md +++ b/REGISTRY.md @@ -23,8 +23,8 @@ Create new annotations by segmenting images or connecting existing annotations. |--------|-------------|-----|-------|------| | Claude natural language analyzer | Uses Claude AI to analyze images | | | [docs](workers/annotations/ai_analysis/AI_ANALYSIS.md) | | Cellori Segmentation | | Yes | | [docs](workers/annotations/cellori_segmentation/CELLORI_SEGMENTATION.md) | -| Cellpose | Uses Cellpose to find cells and nuclei | Yes | | [docs](workers/annotations/cellpose/CELLPOSE.md) | -| Cellpose retrain | Retrains Cellpose models based on user-selected images | Yes | | [docs](workers/annotations/cellpose_train/CELLPOSE_TRAIN.md) | +| Cellpose | This tool runs the Cellpose model to segment the image into cells. Learn more | Yes | | [docs](workers/annotations/cellpose/CELLPOSE.md) | +| Cellpose retrain | This tool trains a Cellpose model using user-corrected annotations. Learn more | Yes | | [docs](workers/annotations/cellpose_train/CELLPOSE_TRAIN.md) | | Cellpose-SAM | This tool runs the Cellpose-SAM model to segment the image into cells. Learn more | Yes | | [docs](workers/annotations/cellposesam/CELLPOSESAM.md) | | CondensateNet segmentation | Segments biomolecular condensates using CondensateNet deep learning model | Yes | | [docs](workers/annotations/condensatenet/CONDENSATENET.md) | | Connect Sequential | This tool connects objects sequentially across time or z-slices.It is useful for connec... | | Yes | [docs](workers/annotations/connect_sequential/CONNECT_SEQUENTIAL.md) | @@ -36,13 +36,13 @@ Create new annotations by segmenting images or connecting existing annotations. | Gaussian Blur | Applies Gaussian blur to images | | Yes | [docs](workers/annotations/gaussian_blur/GAUSSIAN_BLUR.md) | | H&E Deconvolution | Deconvolves H&E stains | | Yes | [docs](workers/annotations/h_and_e_deconvolution/H_AND_E_DECONVOLUTION.md) | | Histogram Matching | Corrects images using histogram matching | | Yes | [docs](workers/annotations/histogram_matching/HISTOGRAM_MATCHING.md) | -| Laplacian of Gaussian | Detects spots in images using the Laplacian of Gaussian method | | | [docs](workers/annotations/laplacian_of_gaussian/LAPLACIAN_OF_GAUSSIAN.md) | +| Laplacian of Gaussian | This tool finds spots in an image using the Laplacian of Gaussian method.It uses a filt... | | | [docs](workers/annotations/laplacian_of_gaussian/LAPLACIAN_OF_GAUSSIAN.md) | | Time lapse registration | Corrects images using time lapse registration | | Yes | [docs](workers/annotations/registration/REGISTRATION.md) | | Rolling Ball | Corrects images using a rolling ball | | Yes | [docs](workers/annotations/rolling_ball/ROLLING_BALL.md) | | SAM2 automatic mask generator | Uses SAM2 to find all masks in the image | Yes | | [docs](workers/annotations/sam2_automatic_mask_generator/SAM2_AUTOMATIC_MASK_GENERATOR.md) | | SAM2 few-shot segmentation | Uses SAM2 features for few-shot segmentation based on training annotations | Yes | Yes | [docs](workers/annotations/sam2_fewshot_segmentation/SAM2_FEWSHOT_SEGMENTATION.md) | -| SAM2 propagator | Uses SAM2 to propagate annotations through time or Z-slices | Yes | | [docs](workers/annotations/sam2_propagate/SAM2_PROPAGATE.md) | -| SAM2 Refiner | Uses SAM2 to refine annotations | Yes | | [docs](workers/annotations/sam2_refine/SAM2_REFINE.md) | +| SAM2 propagator | This tool uses the SAM2 model to take an existing annotation and propagate it through t... | Yes | | [docs](workers/annotations/sam2_propagate/SAM2_PROPAGATE.md) | +| SAM2 Refiner | This tool uses the SAM2 model to refine existing annotations. It takes existing annotat... | Yes | | [docs](workers/annotations/sam2_refine/SAM2_REFINE.md) | | SAM2 video | Uses SAM2 to track video through time or Z | Yes | | [docs](workers/annotations/sam2_video/SAM2_VIDEO.md) | | SAM automatic mask generator | Uses SAM to find all masks in the image | Yes | | [docs](workers/annotations/sam_automatic_mask_generator/SAM_AUTOMATIC_MASK_GENERATOR.md) | | SAM few-shot segmentation | Uses SAM1 ViT-H features for few-shot segmentation based on training annotations | Yes | Yes | [docs](workers/annotations/sam_fewshot_segmentation/SAM_FEWSHOT_SEGMENTATION.md) | @@ -54,16 +54,16 @@ Compute properties on polygon / blob annotations. | Worker | Description | Tests | Docs | |--------|-------------|-------|------| -| Annulus intensity percentile measurements | Compute intensity at a specific percentile for an annular region around blobs | Yes | [docs](workers/properties/blobs/blob_annulus_intensity_percentile_worker/BLOB_ANNULUS_INTENSITY_PERCENTILE_WORKER.md) | -| Annulus intensity measurements | Compute intensity measurements (mean, median, etc.) for an annular region around blobs | Yes | [docs](workers/properties/blobs/blob_annulus_intensity_worker/BLOB_ANNULUS_INTENSITY_WORKER.md) | +| Annulus intensity percentile measurements | This tool computes the pixel intensity in an annulus around the objects in the specifie... | Yes | [docs](workers/properties/blobs/blob_annulus_intensity_percentile_worker/BLOB_ANNULUS_INTENSITY_PERCENTILE_WORKER.md) | +| Annulus intensity measurements | This tool computes the pixel intensity in an annulus around the objects in the specifie... | Yes | [docs](workers/properties/blobs/blob_annulus_intensity_worker/BLOB_ANNULUS_INTENSITY_WORKER.md) | | Colony two color intensity | Compute intensity across two channels for blobs; useful for colony intensity measurements | | [docs](workers/properties/blobs/blob_colony_two_color_intensity_worker/BLOB_COLONY_TWO_COLOR_INTENSITY_WORKER.md) | -| Blob intensity percentile measurements | Compute intensity at a specific percentile for blobs | Yes | [docs](workers/properties/blobs/blob_intensity_percentile_worker/BLOB_INTENSITY_PERCENTILE_WORKER.md) | -| Blob intensity measurements | Compute intensity measurements (mean, median, etc.) for blobs | Yes | [docs](workers/properties/blobs/blob_intensity_worker/BLOB_INTENSITY_WORKER.md) | -| Blob metrics | Compute area, perimeter, and other metrics for blobs | Yes | [docs](workers/properties/blobs/blob_metrics_worker/BLOB_METRICS_WORKER.md) | -| Blob overlap | Compute overlap between blobs | Yes | [docs](workers/properties/blobs/blob_overlap_worker/BLOB_OVERLAP_WORKER.md) | +| Blob intensity percentile measurements | This tool computes the pixel intensity of objects in the specified channel at the speci... | Yes | [docs](workers/properties/blobs/blob_intensity_percentile_worker/BLOB_INTENSITY_PERCENTILE_WORKER.md) | +| Blob intensity measurements | This tool computes the pixel intensity of objects in the specified channel. It will com... | Yes | [docs](workers/properties/blobs/blob_intensity_worker/BLOB_INTENSITY_WORKER.md) | +| Blob metrics | This tool computes a variety of metrics for the objects in the specified channel. The m... | Yes | [docs](workers/properties/blobs/blob_metrics_worker/BLOB_METRICS_WORKER.md) | +| Blob overlap | This tool computes the overlaps between two sets of annotations. The overlap is compute... | Yes | [docs](workers/properties/blobs/blob_overlap_worker/BLOB_OVERLAP_WORKER.md) | | Point count 3D projection | Count points in a 3D projection | | [docs](workers/properties/blobs/blob_point_count_3D_projection_worker/BLOB_POINT_COUNT_3D_PROJECTION_WORKER.md) | -| Point count | Count the number of points that fall within a blob/polygon; can work in 2D or 3D | Yes | [docs](workers/properties/blobs/blob_point_count_worker/BLOB_POINT_COUNT_WORKER.md) | -| Random Forest Classifier | Classify blobs using a Random Forest Classifier | Yes | [docs](workers/properties/blobs/blob_random_forest_classifier/BLOB_RANDOM_FOREST_CLASSIFIER.md) | +| Point count | This tool counts the number of points within polygon annotations. The points will be of... | Yes | [docs](workers/properties/blobs/blob_point_count_worker/BLOB_POINT_COUNT_WORKER.md) | +| Random Forest Classifier | This tool uses a Random Forest Classifier to classify blobs. | Yes | [docs](workers/properties/blobs/blob_random_forest_classifier/BLOB_RANDOM_FOREST_CLASSIFIER.md) | ## Property Workers -- Points @@ -72,9 +72,9 @@ Compute properties on point annotations. | Worker | Description | Tests | Docs | |--------|-------------|-------|------| | Point circle intensity | Computes mean intensity metrics around a point; radius 1 for pixel precision | | [docs](workers/properties/points/point_circle_intensity_mean_worker/POINT_CIRCLE_INTENSITY_MEAN_WORKER.md) | -| Point intensity | Computes a variety of intensity metrics around a point; radius 1 for pixel precision | Yes | [docs](workers/properties/points/point_circle_intensity_worker/POINT_CIRCLE_INTENSITY_WORKER.md) | +| Point intensity | This tool computes the average, maximum, minimum, median, total, 25th percentile, and 7... | Yes | [docs](workers/properties/points/point_circle_intensity_worker/POINT_CIRCLE_INTENSITY_WORKER.md) | | Point Intensity Worker | | | [docs](workers/properties/points/point_intensity_worker/POINT_INTENSITY_WORKER.md) | -| Point metrics | Computes a variety of metrics for points, like X and Y coordinates | Yes | [docs](workers/properties/points/point_metrics_worker/POINT_METRICS_WORKER.md) | +| Point metrics | This tool adds a property to the points to document their coordinates. It gives each po... | Yes | [docs](workers/properties/points/point_metrics_worker/POINT_METRICS_WORKER.md) | | Point Threshold Intensity Mean Worker | | | [docs](workers/properties/points/point_threshold_intensity_mean_worker/POINT_THRESHOLD_INTENSITY_MEAN_WORKER.md) | | Distance to nearest blob | | Yes | [docs](workers/properties/points/point_to_nearest_blob_distance/POINT_TO_NEAREST_BLOB_DISTANCE.md) | | Distance from point to nearest other point | | | [docs](workers/properties/points/point_to_nearest_connected_point_distance/POINT_TO_NEAREST_CONNECTED_POINT_DISTANCE.md) | @@ -86,8 +86,8 @@ Compute properties on line annotations. | Worker | Description | Tests | Docs | |--------|-------------|-------|------| -| length | Compute the length of lines | | [docs](workers/properties/lines/line_length_worker/LINE_LENGTH_WORKER.md) | -| Line scan CSV | This tool computes the intensity along a line and puts the results in a CSV file. The i... | | [docs](workers/properties/lines/line_scan_worker/LINE_SCAN_WORKER.md) | +| length | Computes the length of lines. | | [docs](workers/properties/lines/line_length_worker/LINE_LENGTH_WORKER.md) | +| Line scan CSV | This tool computes the intensity along a line and puts the results in a CSV file. The i... | Yes | [docs](workers/properties/lines/line_scan_worker/LINE_SCAN_WORKER.md) | | Test File Creation | | | [docs](workers/properties/lines/test_file_creation_worker/TEST_FILE_CREATION_WORKER.md) | ## Property Workers -- Connections @@ -96,8 +96,8 @@ Compute properties based on relationships between annotations. | Worker | Description | Tests | Docs | |--------|-------------|-------|------| -| Count connected objects | Count the number of children objects that are connected to a parent polygon | Yes | [docs](workers/properties/connections/children_count_worker/CHILDREN_COUNT_WORKER.md) | -| Connection IDs | Creates a set of identifiers that indicate the parent-child relationships between polygons | Yes | [docs](workers/properties/connections/parent_child_worker/PARENT_CHILD_WORKER.md) | +| Count connected objects | This tool counts the number of children objects that are connected to a parent polygon.... | Yes | [docs](workers/properties/connections/children_count_worker/CHILDREN_COUNT_WORKER.md) | +| Connection IDs | This tool adds a property to the objects to document the connections between them, whic... | Yes | [docs](workers/properties/connections/parent_child_worker/PARENT_CHILD_WORKER.md) | ## Test / Sample Workers @@ -108,8 +108,8 @@ Workers used for testing and demonstration -- not intended for production use. | Annulus Generator M1 | | | | [docs](workers/annotations/annulus_generator_M1/ANNULUS_GENERATOR_M1.md) | | Random point | Creates random point annotations | | | [docs](workers/annotations/random_point/RANDOM_POINT.md) | | Random Point Annotation M1 | | | | [docs](workers/annotations/random_point_annotation_M1/RANDOM_POINT_ANNOTATION_M1.md) | -| Random squares | Generates random square polygon annotations for testing | | Yes | [docs](workers/annotations/random_squares/RANDOM_SQUARES.md) | -| Sample interface | Demonstrates all interface types, messaging patterns, and batch mode | | Yes | [docs](workers/annotations/sample_interface/SAMPLE_INTERFACE.md) | +| Random squares | Generates random square polygon annotations within the image bounds. Useful for testing... | | Yes | [docs](workers/annotations/random_squares/RANDOM_SQUARES.md) | +| Sample interface | This is a sample interface worker that demonstrates all available interface types, tool... | | Yes | [docs](workers/annotations/sample_interface/SAMPLE_INTERFACE.md) | | Test Multiple Annotation | | | | [docs](workers/annotations/test_multiple_annotation/TEST_MULTIPLE_ANNOTATION.md) | | Test Multiple Annotation M1 | | | | [docs](workers/annotations/test_multiple_annotation_M1/TEST_MULTIPLE_ANNOTATION_M1.md) | diff --git a/annotation_utilities/annotation_utilities/coordinate_validation.py b/annotation_utilities/annotation_utilities/coordinate_validation.py new file mode 100644 index 0000000..bdf6a5e --- /dev/null +++ b/annotation_utilities/annotation_utilities/coordinate_validation.py @@ -0,0 +1,93 @@ +"""Validate batch coordinates against a dataset's dimensions. + +These are pure helpers (no I/O, no ``sendError``) so they can be unit-tested +directly and reused by any worker. The caller decides what to do with the +result (typically ``sendError`` + raise). + +Coordinates are 0-indexed internally (as the batch parser emits them after its +1->0 conversion), but messages are rendered 1-indexed to match what the user +typed in the 'Batch XY/Z/Time' UI fields. +""" + +# Dimension key in tiles['IndexRange'] for each dimension we validate. +INDEX_KEYS = {'XY': 'IndexXY', 'Z': 'IndexZ', 'Time': 'IndexT'} + +# Human-facing label (matches the UI field name) for each dimension. +LABELS = {'XY': 'Batch XY', 'Z': 'Batch Z', 'Time': 'Batch Time'} + +# Stable order in which dimensions appear in combined messages. +_ORDER = ('XY', 'Z', 'Time') + + +def _dimension_size(index_range, dim): + """Number of positions along ``dim``; defaults to 1 when unknown. + + A missing ``IndexRange`` or a missing per-dimension key both mean the + dataset has a single position along that dimension (only coord 0 valid), + matching the existing fallback convention used elsewhere in the repo. + """ + if not index_range: + return 1 + return index_range.get(INDEX_KEYS[dim], 1) + + +def find_out_of_range(index_range, xys=None, zs=None, times=None): + """Find requested coordinates that fall outside the dataset. + + :param dict index_range: ``tiles['IndexRange']`` (may be empty/missing keys). + :param xys/zs/times: iterables of requested 0-indexed coordinates, or + ``None`` to skip that dimension. + :return: ``{dim: (sorted_bad_0indexed_values, dimension_size)}`` for every + dimension with at least one out-of-range coordinate. Empty dict means + everything requested is valid. + """ + requested = (('XY', xys), ('Z', zs), ('Time', times)) + invalid = {} + for dim, values in requested: + if values is None: + continue + size = _dimension_size(index_range, dim) + bad = sorted({v for v in values if v < 0 or v >= size}) + if bad: + invalid[dim] = (bad, size) + return invalid + + +def _phrase(bad_zero_indexed, capitalized): + """Render a list of bad 0-indexed coordinates as a 1-indexed phrase.""" + one_indexed = sorted(v + 1 for v in bad_zero_indexed) + if (len(one_indexed) > 1 + and one_indexed[-1] - one_indexed[0] + 1 == len(one_indexed)): + span = f"{one_indexed[0]}-{one_indexed[-1]}" + else: + span = ", ".join(str(v) for v in one_indexed) + + if len(one_indexed) == 1: + noun, verb = "Position", "does" + else: + noun, verb = "Positions", "do" + if not capitalized: + noun = noun.lower() + return f"{noun} {span} {verb} not exist" + + +def format_out_of_range_message(invalid): + """Build a short ``(message, info)`` pair for ``sendError``. + + :param invalid: the dict returned by :func:`find_out_of_range` (non-empty). + :return: ``(message, info)``. For a single offending dimension the message + is e.g. ``"Batch XY out of range"`` / ``"Positions 80-90 do not exist"``; + for several, ``"Batch coordinates out of range"`` and a combined info. + """ + dims = [d for d in _ORDER if d in invalid] + + if len(dims) == 1: + dim = dims[0] + bad, _size = invalid[dim] + return f"{LABELS[dim]} out of range", _phrase(bad, capitalized=True) + + parts = [] + for dim in dims: + bad, _size = invalid[dim] + parts.append(f"{LABELS[dim]}: {_phrase(bad, capitalized=False)}") + return "Batch coordinates out of range", "; ".join(parts) diff --git a/annotation_utilities/tests/test_coordinate_validation.py b/annotation_utilities/tests/test_coordinate_validation.py new file mode 100644 index 0000000..1672681 --- /dev/null +++ b/annotation_utilities/tests/test_coordinate_validation.py @@ -0,0 +1,103 @@ +import sys +from pathlib import Path + +package_root = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(package_root)) + +from annotation_utilities import coordinate_validation as cv + + +# --------------------------------------------------------------------------- +# find_out_of_range +# --------------------------------------------------------------------------- + +def test_all_in_range_returns_empty(): + assert cv.find_out_of_range({'IndexXY': 5}, xys=[0, 1, 4]) == {} + + +def test_xy_out_of_range_detected_with_size(): + # size 5 => valid 0-indexed 0..4; 5 and 6 are out of range + assert cv.find_out_of_range({'IndexXY': 5}, xys=[4, 5, 6]) == { + 'XY': ([5, 6], 5) + } + + +def test_missing_index_range_defaults_size_to_one(): + # No IndexRange at all => every dimension has size 1 (only coord 0 valid) + assert cv.find_out_of_range({}, xys=[0]) == {} + assert cv.find_out_of_range({}, xys=[1]) == {'XY': ([1], 1)} + + +def test_missing_subkey_defaults_that_dimension_to_one(): + # IndexXY present but IndexZ absent => Z size defaults to 1 + index_range = {'IndexXY': 3} + assert cv.find_out_of_range(index_range, zs=[0]) == {} + assert cv.find_out_of_range(index_range, zs=[1]) == {'Z': ([1], 1)} + + +def test_negative_coordinate_is_out_of_range(): + assert cv.find_out_of_range({'IndexXY': 5}, xys=[-1]) == {'XY': ([-1], 5)} + + +def test_multiple_dimensions_reported_together(): + index_range = {'IndexXY': 2, 'IndexZ': 2, 'IndexT': 2} + result = cv.find_out_of_range(index_range, xys=[2], zs=[0], times=[5, 6]) + assert result == {'XY': ([2], 2), 'Time': ([5, 6], 2)} + + +def test_none_dimensions_are_skipped(): + # zs is None => never checked, even though it would be out of range + assert cv.find_out_of_range({'IndexXY': 5}, xys=[0]) == {} + + +def test_bad_values_are_sorted_and_deduplicated(): + assert cv.find_out_of_range({'IndexXY': 5}, xys=[6, 5, 6, 5]) == { + 'XY': ([5, 6], 5) + } + + +def test_empty_requested_list_is_in_range(): + assert cv.find_out_of_range({'IndexXY': 1}, xys=[]) == {} + + +# --------------------------------------------------------------------------- +# format_out_of_range_message +# --------------------------------------------------------------------------- + +def test_message_single_contiguous_range_is_one_indexed(): + # 0-indexed 79..89 (the reported "80-90" input) => display 80-90 + invalid = {'XY': (list(range(79, 90)), 1)} + message, info = cv.format_out_of_range_message(invalid) + assert message == 'Batch XY out of range' + assert info == 'Positions 80-90 do not exist' + + +def test_message_single_position_is_singular(): + invalid = {'Z': ([4], 1)} + message, info = cv.format_out_of_range_message(invalid) + assert message == 'Batch Z out of range' + assert info == 'Position 5 does not exist' + + +def test_message_non_contiguous_positions_are_comma_listed(): + invalid = {'XY': ([4, 6], 5)} + message, info = cv.format_out_of_range_message(invalid) + assert message == 'Batch XY out of range' + assert info == 'Positions 5, 7 do not exist' + + +def test_message_multiple_dimensions_combined(): + invalid = {'XY': ([79, 80], 1), 'Z': ([4], 1)} + message, info = cv.format_out_of_range_message(invalid) + assert message == 'Batch coordinates out of range' + assert info == ( + 'Batch XY: positions 80-81 do not exist; ' + 'Batch Z: position 5 does not exist' + ) + + +def test_message_uses_correct_labels_for_each_dimension(): + _, info = cv.format_out_of_range_message({'Time': ([9], 5)}) + message, _ = cv.format_out_of_range_message({'Time': ([9], 5)}) + assert message == 'Batch Time out of range' + assert info == 'Position 10 does not exist' diff --git a/docs/superpowers/specs/2026-05-30-out-of-range-coordinate-validation-design.md b/docs/superpowers/specs/2026-05-30-out-of-range-coordinate-validation-design.md new file mode 100644 index 0000000..54a0468 --- /dev/null +++ b/docs/superpowers/specs/2026-05-30-out-of-range-coordinate-validation-design.md @@ -0,0 +1,214 @@ +# Out-of-range coordinate validation in the WorkerClient path + +**Date:** 2026-05-30 +**Status:** Design — pending implementation + +## Problem + +When a user enters a `Batch XY`, `Batch Z`, or `Batch Time` range that includes +coordinates not present in the dataset, annotation workers crash with a raw +`KeyError` and a full stack trace instead of a useful, actionable message. + +Reproduction (reported): running Cellpose-SAM with `Batch XY: "80-90"` on a +dataset that has only one XY position. The parser converts the 1-indexed +`80-90` to 0-indexed `79-89`; the worker then crashes at: + +``` +File ".../annotation_client/tiles.py", line 134, in coordinatesToFrameIndex + return self.map[channel][T][Z][XY] +KeyError: 79 +``` + +### Crash path + +`WorkerClient.process()` parses `Batch XY/Z/Time` into ranges, then loops: +`process()` → `get_image_stack()` → `get_image()` → `coordinatesToFrameIndex()`, +which does `self.map[channel][T][Z][XY]` and raises a bare `KeyError` the moment +a coordinate is missing. No `sendError`, so the user sees only a stack trace. + +### Scope of affected code (from a full sweep) + +- **6 workers go through the shared `WorkerClient.process()` path** — cellposesam, + cellpose, condensatenet, laplacian_of_gaussian, random_squares, + sample_interface. A single central fix covers all of them. +- **~26 other workers** iterate batch ranges or call `coordinatesToFrameIndex()` + directly (SAM2 family, cellori, registration, crop, histogram_matching, + cellpose_train, etc.). These are **out of scope** for this change; the shared + validator added here is written so they can adopt it later. + +Existing in-repo validation patterns this design follows: `blob_intensity_worker`, +`crop`, and `registration` build `range_x = range(0, IndexRange['IndexXY'])`, +filter/validate coordinates, and report in 1-indexed terms. + +## Decisions + +- **Behavior:** *Strict* — if **any** requested coordinate is out of range, + `sendError` with the valid range and stop before doing work. (Not the + filter-and-warn-subset approach used by some property workers.) +- **Scope:** Central `WorkerClient.process()` path first. Other workers deferred. +- **No cross-repo changes:** The shared `annotation_client/tiles.py` + (`coordinatesToFrameIndex`) in the NimbusImage repo keeps its bare `KeyError` + as a last-resort backstop. A separate GitHub issue is filed there to harden it + later. +- **Fail before expensive work:** Cellpose-SAM loads its GPU model in + `compute()` *before* `worker.process()` runs. To fail instantly (before the + ~5-10s model load), `cellposesam.compute()` calls the validator early. + +## Design + +### 1. Pure validator — `annotation_utilities` + +New module `annotation_utilities/annotation_utilities/coordinate_validation.py`. +Pure functions, no `sendError` side effects (keeps them unit-testable and +reusable by the deferred workers): + +```python +def find_out_of_range(index_range, xys=None, zs=None, times=None): + """Return {dim: (sorted_bad_0indexed_values, dim_size)} for any requested + coordinate < 0 or >= the dimension's size. Empty dict means all valid. + + `dim` is one of 'XY', 'Z', 'Time'. A missing IndexRange, or a missing + IndexXY/IndexZ/IndexT sub-key, defaults that dimension's size to 1 — + matching the existing fallback convention (blob_intensity, get_image_stack). + Dimensions passed as None are skipped.""" + +def format_out_of_range_message(invalid): + """Build a single (message, info) pair for sendError. Kept short because the + error display is compact. XY/Z/Time values are reported 1-INDEXED to match + what the user typed in the 'Batch XY/Z/Time' UI fields (the batch parser + converts 1->0 on input). Contiguous runs collapse to 'a-b'; otherwise a + comma-separated list. + + Single offending dimension: + message = "Batch