xenium-splitter 0.2.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1104 @@
1
+ """Image I/O and masking utilities for xenium_splitter.
2
+
3
+ Coordinate System:
4
+ The xenium_splitter uses a unified coordinate system where:
5
+ - Coordinate space: Micrometers (um), from experiment.xenium or raw polygon bounds
6
+ - Pixel space: Integer pixel indices in the cropped image
7
+ - Conversion: pixel = coordinate / pixel_size_um (when pixel_size_um provided)
8
+
9
+ Image Cropping and Masking:
10
+ When splitting regions:
11
+ 1. Polygon bounds are converted to pixel space via _polygon_to_pixel_space()
12
+ 2. Crop bounding box is computed with integer pixel bounds using _bbox_int()
13
+ 3. Cropped region is extracted from the image
14
+ 4. Polygon is translated to local (crop-relative) coordinates
15
+ 5. Local mask is applied via _apply_local_mask()
16
+
17
+ Coordinate Rebasing:
18
+ Entity coordinates (cells, transcripts, nucleus) in tables must be rebased
19
+ to align with the cropped image:
20
+ - Crop origin: floor(polygon_min_bounds / pixel_size) in pixel space
21
+ - Entity coordinates -= origin (in coordinate units)
22
+ This alignment happens in io_utils.rebase_table_coordinates_to_region_crop().
23
+ """
24
+ from __future__ import annotations
25
+
26
+ import logging
27
+ from pathlib import Path
28
+ from typing import Any
29
+
30
+ import numpy as np
31
+ import tifffile
32
+ from PIL import Image, ImageDraw, ImageFont
33
+ from shapely import affinity
34
+ from shapely.geometry import Polygon
35
+
36
+ logger = logging.getLogger(__name__)
37
+
38
+
39
+ def read_image(path: Path, squash_layers: bool = True) -> np.ndarray:
40
+ """Read an image file into a NumPy array.
41
+
42
+ Supports SVS (via ``openslide``), TIFF/OME-TIFF (via ``tifffile``), and
43
+ common raster formats (PNG, JPEG, etc.) via Pillow. Multi-layer arrays
44
+ are max-projected when ``squash_layers`` is ``True``.
45
+
46
+ Args:
47
+ path: Path to the image file.
48
+ squash_layers: When ``True``, flatten multi-dimensional stacks to 2D/3D.
49
+
50
+ Returns:
51
+ Image as a NumPy array (H × W or H × W × C).
52
+ """
53
+ lower_name = path.name.lower()
54
+ if path.suffix.lower() == ".svs":
55
+ return _read_svs(path)
56
+
57
+ if lower_name.endswith((".tif", ".tiff", ".ome.tif", ".ome.tiff")):
58
+ array = tifffile.imread(path)
59
+ return _squash_if_needed(array, path=path) if squash_layers else np.asarray(array)
60
+
61
+ with Image.open(path) as img:
62
+ return np.asarray(img.convert("RGB"))
63
+
64
+
65
+ def supports_windowed_region_read(path: Path) -> bool:
66
+ """Return ``True`` when the image format supports windowed (partial) region reads.
67
+
68
+ Windowed reading avoids loading the full image when only a small region crop
69
+ is needed. Currently supported for TIFF/OME-TIFF and SVS files.
70
+ """
71
+ lower_name = path.name.lower()
72
+ return path.suffix.lower() == ".svs" or lower_name.endswith(
73
+ (".tif", ".tiff", ".ome.tif", ".ome.tiff")
74
+ )
75
+
76
+
77
+ def read_masked_cropped_region(
78
+ path: Path,
79
+ polygon: Polygon,
80
+ pixel_size_um: float | None = None,
81
+ squash_layers: bool = True,
82
+ ) -> np.ndarray:
83
+ """Read, crop, and mask a single polygon region from an image file.
84
+
85
+ Dispatches to the appropriate windowed reader for TIFF/SVS, or falls back
86
+ to a full-read + crop for other formats.
87
+
88
+ Args:
89
+ path: Path to the source image.
90
+ polygon: Region polygon in coordinate space (micrometers).
91
+ pixel_size_um: Pixel size in micrometers; used to convert polygon coordinates
92
+ to pixel indices. ``None`` treats the polygon as already in pixel space.
93
+ squash_layers: Flatten multi-layer stacks when ``True``.
94
+
95
+ Returns:
96
+ Masked and cropped image array.
97
+ """
98
+ if path.suffix.lower() == ".svs":
99
+ return _read_masked_cropped_svs_region(path, polygon, pixel_size_um)
100
+
101
+ lower_name = path.name.lower()
102
+ if lower_name.endswith((".tif", ".tiff", ".ome.tif", ".ome.tiff")):
103
+ return _read_masked_cropped_tiff_region(path, polygon, pixel_size_um, squash_layers)
104
+
105
+ image = read_image(path, squash_layers=squash_layers)
106
+ return mask_and_crop_region(image, polygon, pixel_size_um=pixel_size_um)
107
+
108
+
109
+ def _read_svs(path: Path) -> np.ndarray:
110
+ """Read a full SVS slide as an RGB NumPy array (requires ``openslide-python``)."""
111
+ try:
112
+ import openslide
113
+ except ImportError as exc:
114
+ raise RuntimeError(
115
+ "SVS reading requires openslide-python and OpenSlide shared libraries."
116
+ ) from exc
117
+
118
+ with openslide.OpenSlide(str(path)) as slide:
119
+ width, height = slide.dimensions
120
+ region = slide.read_region((0, 0), 0, (width, height))
121
+ return np.asarray(region.convert("RGB"))
122
+
123
+
124
+ def _read_masked_cropped_svs_region(
125
+ path: Path,
126
+ polygon: Polygon,
127
+ pixel_size_um: float | None,
128
+ ) -> np.ndarray:
129
+ """Read a polygon-bounded crop of an SVS slide, applying a binary mask.
130
+
131
+ Uses ``openslide`` windowed reads; only the bounding box of the polygon
132
+ is loaded from disk.
133
+ """
134
+ try:
135
+ import openslide
136
+ except ImportError as exc:
137
+ raise RuntimeError(
138
+ "SVS reading requires openslide-python and OpenSlide shared libraries."
139
+ ) from exc
140
+
141
+ polygon_px = _polygon_to_pixel_space(polygon, pixel_size_um)
142
+ min_x_i, min_y_i, max_x_i, max_y_i = _bbox_int(polygon_px.bounds)
143
+ if min_x_i >= max_x_i or min_y_i >= max_y_i:
144
+ return np.empty((0, 0), dtype=np.uint8)
145
+
146
+ with openslide.OpenSlide(str(path)) as slide:
147
+ width, height = slide.dimensions
148
+ min_x_i = max(min_x_i, 0)
149
+ min_y_i = max(min_y_i, 0)
150
+ max_x_i = min(max_x_i, width)
151
+ max_y_i = min(max_y_i, height)
152
+ if min_x_i >= max_x_i or min_y_i >= max_y_i:
153
+ return np.empty((0, 0), dtype=np.uint8)
154
+
155
+ crop_w = max_x_i - min_x_i
156
+ crop_h = max_y_i - min_y_i
157
+ region = slide.read_region((min_x_i, min_y_i), 0, (crop_w, crop_h))
158
+ image = np.asarray(region.convert("RGB"))
159
+ local_polygon = affinity.translate(polygon_px, xoff=-min_x_i, yoff=-min_y_i)
160
+ return _apply_local_mask(image, local_polygon)
161
+
162
+
163
+ def _read_masked_cropped_tiff_region(
164
+ path: Path,
165
+ polygon: Polygon,
166
+ pixel_size_um: float | None,
167
+ squash_layers: bool,
168
+ ) -> np.ndarray:
169
+ """Read TIFF region with masking, using full-read + crop (more reliable than zarr slicing)."""
170
+ polygon_px = _polygon_to_pixel_space(polygon, pixel_size_um)
171
+ min_x_i, min_y_i, max_x_i, max_y_i = _bbox_int(polygon_px.bounds)
172
+ if min_x_i >= max_x_i or min_y_i >= max_y_i:
173
+ return np.empty((0, 0), dtype=np.uint8)
174
+
175
+ tiled_result = (
176
+ _read_tiled_jpeg2000_crop(path, min_x_i, min_y_i, max_x_i, max_y_i)
177
+ if squash_layers
178
+ else None
179
+ )
180
+ if tiled_result is not None:
181
+ cropped, min_x_i_safe, min_y_i_safe, max_x_i_safe, max_y_i_safe = tiled_result
182
+ spatial_axes = (0, 1)
183
+ logger.debug(
184
+ "Read %s crop via tiled JPEG2000: source_bbox=(%d,%d)-(%d,%d), crop_shape=%s",
185
+ path.name,
186
+ min_x_i_safe,
187
+ min_y_i_safe,
188
+ max_x_i_safe,
189
+ max_y_i_safe,
190
+ cropped.shape,
191
+ )
192
+ else:
193
+ memory_mapped = False
194
+ try:
195
+ try:
196
+ full = tifffile.memmap(path, mode="r")
197
+ memory_mapped = True
198
+ except ValueError:
199
+ full = read_image(path, squash_layers=False)
200
+ except Exception as e:
201
+ logger.error(f"Failed to read TIFF {path.name}: {e}")
202
+ raise
203
+
204
+ spatial_axes = _spatial_axes_for_array(full, path=path)
205
+ y_axis, x_axis = spatial_axes
206
+ h = int(full.shape[y_axis])
207
+ w = int(full.shape[x_axis])
208
+ min_x_i_safe = max(min_x_i, 0)
209
+ min_y_i_safe = max(min_y_i, 0)
210
+ max_x_i_safe = min(max_x_i, w)
211
+ max_y_i_safe = min(max_y_i, h)
212
+
213
+ if min_x_i_safe >= max_x_i_safe or min_y_i_safe >= max_y_i_safe:
214
+ logger.warning(
215
+ f"Crop bounds out of image range for {path.name}: "
216
+ f"requested ({min_x_i}, {min_y_i}, {max_x_i}, {max_y_i}), "
217
+ f"image shape ({h}, {w})"
218
+ )
219
+ return np.empty((0, 0), dtype=full.dtype)
220
+
221
+ logger.debug(
222
+ "Read %s crop via %s: source_shape=%s, source_bbox=(%d,%d)-(%d,%d)",
223
+ path.name,
224
+ "memory-mapped TIFF" if memory_mapped else "full TIFF decode",
225
+ full.shape,
226
+ min_x_i_safe,
227
+ min_y_i_safe,
228
+ max_x_i_safe,
229
+ max_y_i_safe,
230
+ )
231
+
232
+ crop_slices = [slice(None)] * full.ndim
233
+ crop_slices[y_axis] = slice(min_y_i_safe, max_y_i_safe)
234
+ crop_slices[x_axis] = slice(min_x_i_safe, max_x_i_safe)
235
+ cropped = full[tuple(crop_slices)]
236
+ if squash_layers:
237
+ cropped = _squash_if_needed(cropped, path=path, source_shape=full.shape)
238
+ spatial_axes = _spatial_axes_for_array(cropped, path=path)
239
+
240
+ local_polygon = affinity.translate(polygon_px, xoff=-min_x_i_safe, yoff=-min_y_i_safe)
241
+ masked = _apply_local_mask(cropped, local_polygon, spatial_axes=spatial_axes)
242
+
243
+ if masked.size == 0 or np.all(masked == 0):
244
+ logger.warning(f"Result is empty or all-black for {path.name} region bbox ({min_x_i_safe}, {min_y_i_safe}, {max_x_i_safe}, {max_y_i_safe})")
245
+
246
+ return masked
247
+
248
+
249
+ def _read_tiled_jpeg2000_crop(
250
+ path: Path,
251
+ min_x: int,
252
+ min_y: int,
253
+ max_x: int,
254
+ max_y: int,
255
+ ) -> tuple[np.ndarray, int, int, int, int] | None:
256
+ """Decode intersecting tiles from grayscale JPEG2000 TIFF planes."""
257
+ try:
258
+ with tifffile.TiffFile(path) as image_file:
259
+ if len(image_file.series) != 1:
260
+ return None
261
+ series = image_file.series[0]
262
+ if len(series.shape) < 2 or not series.axes.endswith("YX"):
263
+ return None
264
+
265
+ pages = image_file.pages
266
+ plane_count = 1
267
+ for dimension in series.shape[:-2]:
268
+ plane_count *= int(dimension)
269
+ if len(pages) != plane_count:
270
+ return None
271
+
272
+ first_page = pages[0]
273
+ page_info = getattr(first_page, "keyframe", first_page)
274
+ if (
275
+ not page_info.is_tiled
276
+ or page_info.compression.name != "JPEG2000"
277
+ or page_info.axes != "YX"
278
+ or page_info.samplesperpixel != 1
279
+ or len(page_info.shape) != 2
280
+ ):
281
+ return None
282
+
283
+ image_height = int(page_info.imagelength)
284
+ image_width = int(page_info.imagewidth)
285
+ min_x_safe = max(min_x, 0)
286
+ min_y_safe = max(min_y, 0)
287
+ max_x_safe = min(max_x, image_width)
288
+ max_y_safe = min(max_y, image_height)
289
+ if min_x_safe >= max_x_safe or min_y_safe >= max_y_safe:
290
+ return (
291
+ np.empty((0, 0), dtype=page.dtype),
292
+ min_x_safe,
293
+ min_y_safe,
294
+ max_x_safe,
295
+ max_y_safe,
296
+ )
297
+
298
+ tile_height = int(page_info.tilelength)
299
+ tile_width = int(page_info.tilewidth)
300
+ tiles_per_row = (image_width + tile_width - 1) // tile_width
301
+ tile_row_start = min_y_safe // tile_height
302
+ tile_row_end = (max_y_safe - 1) // tile_height
303
+ tile_col_start = min_x_safe // tile_width
304
+ tile_col_end = (max_x_safe - 1) // tile_width
305
+ cropped = np.empty(
306
+ (max_y_safe - min_y_safe, max_x_safe - min_x_safe),
307
+ dtype=page_info.dtype,
308
+ )
309
+
310
+ with path.open("rb") as image_stream:
311
+ first_plane = True
312
+ for page in pages:
313
+ current_page_info = getattr(page, "keyframe", page)
314
+ if (
315
+ not current_page_info.is_tiled
316
+ or current_page_info.compression.name != "JPEG2000"
317
+ or current_page_info.axes != "YX"
318
+ or current_page_info.samplesperpixel != 1
319
+ or int(current_page_info.imagelength) != image_height
320
+ or int(current_page_info.imagewidth) != image_width
321
+ or int(current_page_info.tilelength) != tile_height
322
+ or int(current_page_info.tilewidth) != tile_width
323
+ ):
324
+ return None
325
+
326
+ decoder = getattr(page, "decode", current_page_info.decode)
327
+ for tile_row in range(tile_row_start, tile_row_end + 1):
328
+ for tile_col in range(tile_col_start, tile_col_end + 1):
329
+ tile_index = tile_row * tiles_per_row + tile_col
330
+ offset = int(page.dataoffsets[tile_index])
331
+ byte_count = int(page.databytecounts[tile_index])
332
+ image_stream.seek(offset)
333
+ encoded_tile = image_stream.read(byte_count)
334
+ decoded_tile, _, _ = decoder(encoded_tile, tile_index)
335
+ tile = decoded_tile[0, :, :, 0]
336
+
337
+ tile_min_x = tile_col * tile_width
338
+ tile_min_y = tile_row * tile_height
339
+ tile_max_x = min(tile_min_x + tile_width, image_width)
340
+ tile_max_y = min(tile_min_y + tile_height, image_height)
341
+ copy_min_x = max(min_x_safe, tile_min_x)
342
+ copy_min_y = max(min_y_safe, tile_min_y)
343
+ copy_max_x = min(max_x_safe, tile_max_x)
344
+ copy_max_y = min(max_y_safe, tile_max_y)
345
+
346
+ crop_y = slice(copy_min_y - min_y_safe, copy_max_y - min_y_safe)
347
+ crop_x = slice(copy_min_x - min_x_safe, copy_max_x - min_x_safe)
348
+ tile_y = slice(copy_min_y - tile_min_y, copy_max_y - tile_min_y)
349
+ tile_x = slice(copy_min_x - tile_min_x, copy_max_x - tile_min_x)
350
+ crop_view = cropped[crop_y, crop_x]
351
+ tile_view = tile[tile_y, tile_x]
352
+ if first_plane:
353
+ crop_view[...] = tile_view
354
+ else:
355
+ np.maximum(crop_view, tile_view, out=crop_view)
356
+ first_plane = False
357
+
358
+ return cropped, min_x_safe, min_y_safe, max_x_safe, max_y_safe
359
+ except Exception as exc:
360
+ logger.debug("Falling back to full TIFF read for %s: %s", path.name, exc)
361
+ return None
362
+
363
+
364
+ def _squash_if_needed(
365
+ array: np.ndarray,
366
+ path: Path | None = None,
367
+ source_shape: tuple[int, ...] | None = None,
368
+ ) -> np.ndarray:
369
+ """Flatten multi-page TIFF arrays intelligently.
370
+
371
+ For OME-TIFF with axes metadata, flatten based on what each axis represents.
372
+ For Z-stacks: max projection. For time series: take first frame.
373
+ Fallback: max projection for unknown multi-dimensional arrays.
374
+ """
375
+ arr = np.asarray(array)
376
+
377
+ if arr.ndim == 2:
378
+ return arr
379
+ shape_for_rgb_check = source_shape or arr.shape
380
+ if arr.ndim == 3 and shape_for_rgb_check[-1] in (3, 4):
381
+ return arr
382
+
383
+ axes = _get_tiff_axes(path) if path else None
384
+ if axes:
385
+ return _squash_ome_by_axes(arr, axes)
386
+
387
+ if arr.ndim == 3:
388
+ return np.max(arr, axis=0)
389
+ if arr.ndim == 4:
390
+ return np.max(arr, axis=0)
391
+ return np.squeeze(arr)
392
+
393
+
394
+ def _get_tiff_axes(path: Path) -> str | None:
395
+ """Extract axes string from OME-TIFF metadata."""
396
+ if not path.name.lower().endswith((".ome.tif", ".ome.tiff")):
397
+ return None
398
+ try:
399
+ with tifffile.TiffFile(path) as tif:
400
+ if tif.is_ome:
401
+ series = tif.series[0]
402
+ return series.axes
403
+ except Exception:
404
+ pass
405
+ return None
406
+
407
+
408
+ def _squash_ome_by_axes(arr: np.ndarray, axes: str) -> np.ndarray:
409
+ """Flatten multi-dimensional OME-TIFF using axis knowledge.
410
+
411
+ Axes: Z=depth, T=time, C=channel, Y=row, X=column.
412
+ Keeps Y, X (spatial) and final C if RGB/RGBA.
413
+ Takes max projection for Z and T, collapses other dimensions.
414
+ """
415
+ if arr.ndim <= 2:
416
+ return arr
417
+ if arr.ndim == 3 and arr.shape[-1] in (3, 4):
418
+ return arr
419
+
420
+ axes_lower = axes.lower()
421
+ keep_axes: list[int] = []
422
+ for i, ax in enumerate(axes_lower):
423
+ is_spatial = ax in {"y", "x"}
424
+ is_color_channel = ax == "c" and i == len(axes_lower) - 1 and arr.shape[i] in (3, 4)
425
+ if is_spatial or is_color_channel:
426
+ keep_axes.append(i)
427
+
428
+ if len(keep_axes) == arr.ndim:
429
+ return arr
430
+
431
+ result = arr
432
+ for i in range(arr.ndim - 1, -1, -1):
433
+ if i not in keep_axes:
434
+ result = np.max(result, axis=i)
435
+
436
+ return result
437
+
438
+
439
+ def mask_and_crop_region(image: np.ndarray, polygon: Polygon, pixel_size_um: float | None = None) -> np.ndarray:
440
+ """Mask and crop image to polygon bounds.
441
+
442
+ Workflow:
443
+ 1. Convert polygon from coordinate space to pixel space using pixel_size_um
444
+ 2. Compute integer bounding box and clamp to image dimensions
445
+ 3. Crop image to bounding box
446
+ 4. Translate polygon to local (crop-relative) coordinates
447
+ 5. Apply binary mask (polygon interior = keep, exterior = 0)
448
+
449
+ Args:
450
+ image: Input image array (2D or 3D)
451
+ polygon: Shapely Polygon in coordinate space (micrometers)
452
+ pixel_size_um: Pixel size in micrometers (for coordinate conversion)
453
+
454
+ Returns:
455
+ Masked and cropped image array
456
+ """
457
+ if image.ndim not in (2, 3):
458
+ raise ValueError(f"Expected 2D or 3D image array, got shape {image.shape}")
459
+
460
+ polygon_px = _polygon_to_pixel_space(polygon, pixel_size_um)
461
+ height, width = image.shape[:2]
462
+ min_x_i, min_y_i, max_x_i, max_y_i = _bbox_int(polygon_px.bounds)
463
+ min_x_i = max(min_x_i, 0)
464
+ min_y_i = max(min_y_i, 0)
465
+ max_x_i = min(max_x_i, width)
466
+ max_y_i = min(max_y_i, height)
467
+ bbox_width_px = max(0, max_x_i - min_x_i)
468
+ bbox_height_px = max(0, max_y_i - min_y_i)
469
+ bbox_width_um = bbox_width_px * pixel_size_um if pixel_size_um is not None and pixel_size_um > 0 else None
470
+ bbox_height_um = bbox_height_px * pixel_size_um if pixel_size_um is not None and pixel_size_um > 0 else None
471
+ logger.debug(
472
+ "Image crop bbox: image_shape=%s bbox_px=(x:%d-%d,y:%d-%d,w:%d,h:%d) bbox_um=(w:%s,h:%s)",
473
+ image.shape,
474
+ min_x_i,
475
+ max_x_i,
476
+ min_y_i,
477
+ max_y_i,
478
+ bbox_width_px,
479
+ bbox_height_px,
480
+ f"{bbox_width_um:.3f}" if bbox_width_um is not None else "n/a",
481
+ f"{bbox_height_um:.3f}" if bbox_height_um is not None else "n/a",
482
+ )
483
+ if min_x_i >= max_x_i or min_y_i >= max_y_i:
484
+ return np.empty((0, 0), dtype=image.dtype)
485
+
486
+ cropped = image[min_y_i:max_y_i, min_x_i:max_x_i]
487
+ local_polygon = affinity.translate(polygon_px, xoff=-min_x_i, yoff=-min_y_i)
488
+ return _apply_local_mask(cropped, local_polygon)
489
+
490
+
491
+ def _polygon_to_pixel_space(polygon: Polygon, pixel_size_um: float | None) -> Polygon:
492
+ """Convert polygon from coordinate space to pixel space.
493
+
494
+ Scales polygon coordinates by 1/pixel_size_um to transform micrometers to pixels.
495
+
496
+ Args:
497
+ polygon: Polygon in coordinate units (micrometers)
498
+ pixel_size_um: Pixel size in micrometers; None means polygon is already in pixel space
499
+
500
+ Returns:
501
+ Polygon in pixel space (or original if pixel_size_um is None)
502
+ """
503
+ if pixel_size_um is not None and pixel_size_um > 0:
504
+ scale_factor = 1.0 / pixel_size_um
505
+ return affinity.scale(polygon, xfact=scale_factor, yfact=scale_factor, origin=(0, 0))
506
+ return polygon
507
+
508
+
509
+ def _bbox_int(bounds: tuple[float, float, float, float]) -> tuple[int, int, int, int]:
510
+ """Convert polygon bounds to integer pixel coordinates.
511
+
512
+ Uses floor() for min and ceil() for max to ensure crop fully contains polygon.
513
+ CRITICAL: This behavior MUST match io_utils._region_crop_origin_um() to align
514
+ entity coordinates with the cropped image.
515
+
516
+ Args:
517
+ bounds: (min_x, min_y, max_x, max_y) as floats
518
+
519
+ Returns:
520
+ (min_x_int, min_y_int, max_x_int, max_y_int) as integers
521
+ """
522
+ min_x, min_y, max_x, max_y = bounds
523
+ return int(np.floor(min_x)), int(np.floor(min_y)), int(np.ceil(max_x)), int(np.ceil(max_y))
524
+
525
+
526
+ def _apply_local_mask(
527
+ image: np.ndarray,
528
+ local_polygon: Polygon,
529
+ spatial_axes: tuple[int, int] | None = None,
530
+ ) -> np.ndarray:
531
+ """Apply a binary polygon mask to a cropped image array.
532
+
533
+ Pixels outside the polygon interior are zeroed. The mask is drawn with
534
+ Pillow and broadcast across non-spatial axes.
535
+
536
+ Args:
537
+ image: Cropped image array (2-D or higher).
538
+ local_polygon: Polygon whose coordinates are relative to the crop origin.
539
+ spatial_axes: ``(y_axis, x_axis)`` indices within ``image.shape``; inferred
540
+ when ``None``.
541
+
542
+ Returns:
543
+ Image array with out-of-polygon pixels set to zero.
544
+ """
545
+ if image.ndim < 2:
546
+ raise ValueError(f"Expected image array with at least 2 dims, got shape {image.shape}")
547
+
548
+ if spatial_axes is None:
549
+ spatial_axes = _spatial_axes_for_array(image)
550
+ y_axis, x_axis = spatial_axes
551
+ crop_h = int(image.shape[y_axis])
552
+ crop_w = int(image.shape[x_axis])
553
+ if crop_h == 0 or crop_w == 0:
554
+ return image
555
+
556
+ # Check polygon validity and bounds
557
+ if local_polygon.is_empty:
558
+ logger.warning("Local polygon is empty after translation")
559
+ return np.zeros_like(image)
560
+
561
+ polygon_bounds = local_polygon.bounds
562
+ if (polygon_bounds[0] >= crop_w or polygon_bounds[2] <= 0 or
563
+ polygon_bounds[1] >= crop_h or polygon_bounds[3] <= 0):
564
+ logger.warning(f"Polygon bounds {polygon_bounds} outside crop region ({crop_w}, {crop_h})")
565
+ return np.zeros_like(image)
566
+
567
+ mask_img = Image.new("L", (crop_w, crop_h), 0)
568
+ draw = ImageDraw.Draw(mask_img)
569
+
570
+ # Convert polygon coords to tuples (int conversion for ImageDraw)
571
+ coords = [(float(x), float(y)) for x, y in local_polygon.exterior.coords]
572
+ try:
573
+ draw.polygon(coords, fill=255)
574
+ except Exception as e:
575
+ logger.error(f"Failed to draw polygon with coords {coords}: {e}")
576
+ return np.zeros_like(image)
577
+
578
+ mask = np.asarray(mask_img, dtype=bool)
579
+
580
+ if mask.sum() == 0:
581
+ logger.warning(f"Mask is all zeros for polygon bounds {polygon_bounds}")
582
+
583
+ if image.ndim == 2:
584
+ return np.where(mask, image, 0)
585
+
586
+ broadcast_mask = mask
587
+ for axis in range(image.ndim):
588
+ if axis not in spatial_axes:
589
+ broadcast_mask = np.expand_dims(broadcast_mask, axis=axis)
590
+ return np.where(broadcast_mask, image, 0)
591
+
592
+
593
+ def _pyramid_levels(image: np.ndarray, axes: str | None = None) -> list[np.ndarray]:
594
+ """Build a list of progressively halved images for a pyramid OME-TIFF.
595
+
596
+ Stops when either spatial dimension drops to 512 px or below.
597
+ Each level is a 2×2 block-average of the previous (preserves dtype).
598
+ """
599
+ levels: list[np.ndarray] = [image]
600
+ spatial_axes = _spatial_axes_for_array(image, axes=axes)
601
+ while True:
602
+ prev = levels[-1]
603
+ y_axis, x_axis = _spatial_axes_for_array(prev, axes=axes)
604
+ h = int(prev.shape[y_axis])
605
+ w = int(prev.shape[x_axis])
606
+ if h <= 512 or w <= 512:
607
+ break
608
+ nh, nw = h // 2, w // 2
609
+ trimmed = _trim_spatial_axes(prev, spatial_axes, nh * 2, nw * 2)
610
+ down = _downsample_spatial_axes(trimmed, spatial_axes, nh, nw)
611
+ levels.append(down.astype(prev.dtype))
612
+ return levels
613
+
614
+
615
+ def _write_pyramidal_ome_tiff(
616
+ image: np.ndarray,
617
+ output_path: Path,
618
+ axes: str | None = None,
619
+ pixel_size_um: float | None = None,
620
+ ) -> None:
621
+ """Write a tiled, pyramidal OME-TIFF compatible with Xenium Explorer.
622
+
623
+ Sub-resolution levels are stored as sub-IFDs (the TIFF pyramid standard).
624
+ Tile size is 1024×1024 per 10x Xenium Explorer guidance.
625
+ When pixel_size_um is provided, PhysicalSizeX/Y are written into the OME-XML
626
+ so Xenium Explorer shows coordinates in micrometers.
627
+ """
628
+ output_path.parent.mkdir(parents=True, exist_ok=True)
629
+ levels = _pyramid_levels(image, axes=axes)
630
+ n_subifds = len(levels) - 1
631
+ metadata: dict = {}
632
+ if axes:
633
+ metadata["axes"] = axes
634
+ if pixel_size_um is not None and pixel_size_um > 0:
635
+ metadata["PhysicalSizeX"] = pixel_size_um
636
+ metadata["PhysicalSizeY"] = pixel_size_um
637
+ metadata["PhysicalSizeXUnit"] = "µm"
638
+ metadata["PhysicalSizeYUnit"] = "µm"
639
+ photometric = "rgb" if _is_rgb_like(image, axes=axes) else "minisblack"
640
+ options: dict = {
641
+ "tile": (1024, 1024),
642
+ "photometric": photometric,
643
+ "compression": "zlib",
644
+ "resolutionunit": "CENTIMETER",
645
+ }
646
+
647
+ def _resolution_for_level(scale: float) -> tuple[float, float] | None:
648
+ if pixel_size_um is None or pixel_size_um <= 0:
649
+ return None
650
+ effective_pixel_size_um = float(pixel_size_um) * float(scale)
651
+ pixels_per_centimeter = 1.0e4 / effective_pixel_size_um
652
+ return (pixels_per_centimeter, pixels_per_centimeter)
653
+
654
+ with tifffile.TiffWriter(output_path, bigtiff=True, ome=True) as tif:
655
+ base_kwargs = dict(options)
656
+ resolution = _resolution_for_level(1.0)
657
+ if resolution is not None:
658
+ base_kwargs["resolution"] = resolution
659
+ tif.write(levels[0], subifds=n_subifds, metadata=metadata, **base_kwargs)
660
+ for level_index, level in enumerate(levels[1:], start=1):
661
+ level_kwargs = dict(options)
662
+ resolution = _resolution_for_level(2.0 ** level_index)
663
+ if resolution is not None:
664
+ level_kwargs["resolution"] = resolution
665
+ tif.write(level, subfiletype=1, **level_kwargs)
666
+
667
+
668
+ def save_image_like(
669
+ source_path: Path,
670
+ output_path: Path,
671
+ image: np.ndarray,
672
+ pixel_size_um: float | None = None,
673
+ ) -> Path:
674
+ """Save ``image`` in a format that matches the source file's format.
675
+
676
+ - TIFF/OME-TIFF → pyramidal OME-TIFF (``_write_pyramidal_ome_tiff``).
677
+ - SVS → pyramidal OME-TIFF with ``YXC`` axes (SVS output not supported).
678
+ - Other raster formats → Pillow write using the original extension.
679
+
680
+ Args:
681
+ source_path: Original image path used to determine the output format.
682
+ output_path: Destination path; parent directories are created if needed.
683
+ image: Array to write.
684
+ pixel_size_um: Physical pixel size in micrometers; written to OME-XML
685
+ so the image registers correctly in Xenium Explorer.
686
+
687
+ Returns:
688
+ Actual path written (may differ from ``output_path`` for SVS inputs).
689
+ """
690
+ output_path.parent.mkdir(parents=True, exist_ok=True)
691
+
692
+ source_name = source_path.name.lower()
693
+ if source_name.endswith((".tif", ".tiff", ".ome.tif", ".ome.tiff")):
694
+ axes = _infer_write_axes(image, source_path)
695
+ _write_pyramidal_ome_tiff(image, output_path, axes=axes, pixel_size_um=pixel_size_um)
696
+ return output_path
697
+
698
+ if source_path.suffix.lower() == ".svs":
699
+ fallback = output_path.with_suffix(".tiff")
700
+ _write_pyramidal_ome_tiff(image, fallback, axes="YXC", pixel_size_um=pixel_size_um)
701
+ return fallback
702
+
703
+ pil_image = Image.fromarray(_coerce_for_pillow(image))
704
+ pil_image.save(output_path)
705
+ return output_path
706
+
707
+
708
+ def convert_svs_to_ome_tiff(svs_path: Path, output_path: Path) -> Path:
709
+ """Convert an SVS slide to a pyramidal OME-TIFF.
710
+
711
+ The full slide is read at the highest resolution level and written as a
712
+ tiled, pyramidal OME-TIFF with ``YXC`` axes.
713
+ """
714
+ image = _read_svs(svs_path)
715
+ output_path.parent.mkdir(parents=True, exist_ok=True)
716
+ _write_pyramidal_ome_tiff(image, output_path, axes="YXC")
717
+ return output_path
718
+
719
+
720
+ def write_array_as_ome_tiff(
721
+ image: np.ndarray,
722
+ output_path: Path,
723
+ pixel_size_um: float | None = None,
724
+ ) -> Path:
725
+ """Write an image array as a pyramidal OME-TIFF.
726
+
727
+ Axes are inferred from the array shape. Use :func:`save_image_like` when
728
+ you need to match the source format of an existing image file.
729
+
730
+ Args:
731
+ image: Array to write.
732
+ output_path: Destination path; parent directories are created if needed.
733
+ pixel_size_um: Physical pixel size in micrometers written to OME-XML.
734
+
735
+ Returns:
736
+ The path that was written.
737
+ """
738
+ output_path.parent.mkdir(parents=True, exist_ok=True)
739
+ axes = _infer_write_axes(image)
740
+ _write_pyramidal_ome_tiff(image, output_path, axes=axes, pixel_size_um=pixel_size_um)
741
+ return output_path
742
+
743
+
744
+ def _infer_write_axes(image: np.ndarray, source_path: Path | None = None) -> str:
745
+ """Infer the OME-TIFF axis string for ``image``.
746
+
747
+ Reads the axis string from the source file's OME metadata when available.
748
+ Falls back to shape-based heuristics: ``YX`` (2-D), ``YXC`` (3-D RGB),
749
+ ``ZYX`` (3-D grayscale), ``TZYX`` (4-D), or ``ZYXC`` (4-D RGB).
750
+ """
751
+ arr = np.asarray(image)
752
+ source_axes = _get_tiff_axes(source_path) if source_path is not None else None
753
+ if source_axes and len(source_axes) == arr.ndim:
754
+ return source_axes
755
+ if arr.ndim == 2:
756
+ return "YX"
757
+ if _is_rgb_like(arr):
758
+ if arr.ndim == 3:
759
+ return "YXC"
760
+ if arr.ndim == 4:
761
+ return "ZYXC"
762
+ if arr.ndim == 3:
763
+ return "ZYX"
764
+ if arr.ndim == 4:
765
+ return "TZYX"
766
+ raise ValueError(f"Unsupported array shape for OME-TIFF: {arr.shape}")
767
+
768
+
769
+ def _is_rgb_like(image: np.ndarray, axes: str | None = None) -> bool:
770
+ """Return ``True`` when ``image`` looks like an RGB or RGBA array.
771
+
772
+ When ``axes`` is provided the C axis is checked directly; otherwise a last
773
+ dimension of 3 or 4 is used as a heuristic.
774
+ """
775
+ arr = np.asarray(image)
776
+ if axes and len(axes) == arr.ndim:
777
+ axes_lower = axes.lower()
778
+ try:
779
+ c_axis = axes_lower.index("c")
780
+ return int(arr.shape[c_axis]) in (3, 4)
781
+ except ValueError:
782
+ return False
783
+ return arr.ndim >= 3 and int(arr.shape[-1]) in (3, 4)
784
+
785
+
786
+ def _spatial_axes_for_array(image: np.ndarray, path: Path | None = None, axes: str | None = None) -> tuple[int, int]:
787
+ """Return ``(y_axis_index, x_axis_index)`` for a given array and optional axes string.
788
+
789
+ Uses OME axes metadata when available (from file or explicit argument),
790
+ then falls back to shape-based heuristics (last two non-color axes).
791
+ """
792
+ arr = np.asarray(image)
793
+ source_axes = axes or (_get_tiff_axes(path) if path is not None else None)
794
+ if source_axes and len(source_axes) == arr.ndim:
795
+ axes_lower = source_axes.lower()
796
+ try:
797
+ return axes_lower.index("y"), axes_lower.index("x")
798
+ except ValueError:
799
+ pass
800
+ if _is_rgb_like(arr, axes=source_axes):
801
+ return arr.ndim - 3, arr.ndim - 2
802
+ return arr.ndim - 2, arr.ndim - 1
803
+
804
+
805
+ def _trim_spatial_axes(
806
+ image: np.ndarray,
807
+ spatial_axes: tuple[int, int],
808
+ target_h: int,
809
+ target_w: int,
810
+ ) -> np.ndarray:
811
+ """Crop ``image`` to ``target_h × target_w`` on the spatial axes (used before downsampling)."""
812
+ slices = [slice(None)] * image.ndim
813
+ y_axis, x_axis = spatial_axes
814
+ slices[y_axis] = slice(0, target_h)
815
+ slices[x_axis] = slice(0, target_w)
816
+ return image[tuple(slices)]
817
+
818
+
819
+ def _downsample_spatial_axes(
820
+ image: np.ndarray,
821
+ spatial_axes: tuple[int, int],
822
+ out_h: int,
823
+ out_w: int,
824
+ ) -> np.ndarray:
825
+ """Halve resolution on spatial axes by 2×2 block averaging (used for pyramid levels)."""
826
+ y_axis, x_axis = spatial_axes
827
+ moved = np.moveaxis(image, (y_axis, x_axis), (-2, -1))
828
+ lead_shape = moved.shape[:-2]
829
+ reshaped = moved.reshape(lead_shape + (out_h, 2, out_w, 2))
830
+ down = reshaped.mean(axis=(-3, -1))
831
+ return np.moveaxis(down, (-2, -1), (y_axis, x_axis))
832
+
833
+
834
+ def _coerce_for_pillow(image: np.ndarray) -> np.ndarray:
835
+ """Convert ``image`` to uint8, clipping float values to the [0, 255] range."""
836
+ arr = np.asarray(image)
837
+ if arr.dtype == np.uint8:
838
+ return arr
839
+ arr = arr.astype(np.float32)
840
+ arr = np.clip(arr, 0, 255)
841
+ return arr.astype(np.uint8)
842
+
843
+
844
+ def generate_morphology_mip(image: np.ndarray) -> np.ndarray:
845
+ """Generate a morphology maximum-intensity projection from a cropped morphology stack.
846
+
847
+ Delegates to :func:`_squash_if_needed` which applies max projection across
848
+ all non-spatial axes.
849
+ """
850
+ return _squash_if_needed(np.asarray(image))
851
+
852
+
853
+ def generate_morphology_focus(image: np.ndarray, path: Path | None = None) -> np.ndarray:
854
+ """Return the best-focus Z-plane from a morphology stack (discards focus stats).
855
+
856
+ See :func:`generate_morphology_focus_with_stats` for details.
857
+ """
858
+ focus_image, _stats = generate_morphology_focus_with_stats(image, path)
859
+ return focus_image
860
+
861
+
862
+ def generate_morphology_focus_with_stats(
863
+ image: np.ndarray,
864
+ path: Path | None = None,
865
+ ) -> tuple[np.ndarray, dict[str, Any] | None]:
866
+ """Generate a morphology focus image from a cropped morphology stack.
867
+
868
+ Uses Laplacian-variance focus scoring across Z-like planes. If the image is
869
+ already 2D or RGB, it is returned unchanged.
870
+ """
871
+ arr = np.asarray(image)
872
+ if arr.ndim == 2:
873
+ return arr, None
874
+ if arr.ndim == 3 and arr.shape[-1] in (3, 4):
875
+ return arr, None
876
+
877
+ stack = _focus_plane_stack(arr, path)
878
+ if stack is None or stack.shape[0] == 0:
879
+ return _squash_if_needed(arr, path=path), None
880
+
881
+ try:
882
+ import cv2
883
+ except ImportError as exc:
884
+ raise RuntimeError(
885
+ "Morphology focus generation requires opencv-python-headless."
886
+ ) from exc
887
+
888
+ focus_scores: list[float] = []
889
+ for plane in stack:
890
+ plane_gray = _focus_plane_to_grayscale(plane)
891
+ lap = cv2.Laplacian(plane_gray, cv2.CV_32F)
892
+ focus_scores.append(float(lap.var()))
893
+
894
+ best_index = int(np.argmax(np.asarray(focus_scores, dtype=np.float64)))
895
+ stats = {
896
+ "stack_count": int(stack.shape[0]),
897
+ "selected_index": int(best_index),
898
+ "focus_scores": [float(score) for score in focus_scores],
899
+ }
900
+ return np.asarray(stack[best_index]).astype(arr.dtype, copy=False), stats
901
+
902
+
903
+ def _focus_plane_stack(image: np.ndarray, path: Path | None = None) -> np.ndarray | None:
904
+ """Reshape a multi-dimensional morphology array into a stack of 2-D (or RGB) planes.
905
+
906
+ Uses OME axis metadata to identify non-spatial (Z/T) axes and moves them to
907
+ the leading dimension. Falls back to simple shape-based heuristics.
908
+ Returns ``None`` when the array is already 2-D or RGB.
909
+ """
910
+ arr = np.asarray(image)
911
+ axes = _get_tiff_axes(path) if path else None
912
+
913
+ if axes and len(axes) == arr.ndim:
914
+ axes_lower = axes.lower()
915
+ keep_axes = [i for i, ax in enumerate(axes_lower) if ax in {"y", "x"}]
916
+ color_axis = next(
917
+ (
918
+ i
919
+ for i, ax in enumerate(axes_lower)
920
+ if ax == "c" and arr.shape[i] in (3, 4)
921
+ ),
922
+ None,
923
+ )
924
+ if color_axis is not None:
925
+ keep_axes.append(color_axis)
926
+ keep_axes = sorted(set(keep_axes), key=lambda idx: idx)
927
+ plane_axes = [i for i in range(arr.ndim) if i not in keep_axes]
928
+ if plane_axes:
929
+ moved = np.moveaxis(arr, plane_axes + keep_axes, range(arr.ndim))
930
+ tail_shape = tuple(moved.shape[len(plane_axes):])
931
+ return moved.reshape((-1,) + tail_shape)
932
+
933
+ if arr.ndim == 3 and arr.shape[-1] not in (3, 4):
934
+ return arr
935
+ if arr.ndim == 4 and arr.shape[-1] in (3, 4):
936
+ return arr.reshape((-1,) + arr.shape[-3:])
937
+ return None
938
+
939
+
940
+ def _focus_plane_to_grayscale(plane: np.ndarray) -> np.ndarray:
941
+ """Convert a single focus plane to a float32 grayscale array for Laplacian scoring.
942
+
943
+ RGB/RGBA planes are converted with standard luminance weights (0.299R + 0.587G + 0.114B).
944
+ Multi-channel planes that are neither 2-D nor RGB are collapsed with max-projection.
945
+ """
946
+ arr = np.asarray(plane)
947
+ if arr.ndim == 2:
948
+ return arr.astype(np.float32, copy=False)
949
+ if arr.ndim == 3 and arr.shape[-1] in (3, 4):
950
+ rgb = arr[..., :3].astype(np.float32, copy=False)
951
+ return (0.299 * rgb[..., 0] + 0.587 * rgb[..., 1] + 0.114 * rgb[..., 2]).astype(np.float32, copy=False)
952
+ return _squash_if_needed(arr).astype(np.float32, copy=False)
953
+
954
+
955
+ def _resize_for_overlay(
956
+ image: np.ndarray,
957
+ *,
958
+ max_dimension_px: int,
959
+ ) -> tuple[np.ndarray, float]:
960
+ """Downscale ``image`` so its longest edge is at most ``max_dimension_px``.
961
+
962
+ Returns the (possibly unchanged) image and the scale factor applied
963
+ (1.0 when no downscaling was needed, >1.0 when the image was shrunk).
964
+ The scale factor converts overlay pixel coordinates back to the original
965
+ resolution.
966
+ """
967
+ if max_dimension_px <= 0:
968
+ raise ValueError("max_dimension_px must be > 0")
969
+
970
+ height_px, width_px = image.shape[:2]
971
+ longest_edge = max(height_px, width_px)
972
+ if longest_edge <= max_dimension_px:
973
+ return image, 1.0
974
+
975
+ scale = float(longest_edge) / float(max_dimension_px)
976
+ new_width = max(1, int(round(width_px / scale)))
977
+ new_height = max(1, int(round(height_px / scale)))
978
+ resized = Image.fromarray(image).resize((new_width, new_height), Image.Resampling.BILINEAR)
979
+ return np.asarray(resized), scale
980
+
981
+
982
+ def render_grid_overlay_image(
983
+ image: np.ndarray,
984
+ *,
985
+ level_name: str,
986
+ tile_size_um: float,
987
+ pixel_size_um: float | None,
988
+ fov_stride_um: tuple[float, float] | None = None,
989
+ fov_size_um: tuple[float, float] | None = None,
990
+ max_dimension_px: int = 2048,
991
+ ) -> tuple[np.ndarray, float]:
992
+ """Render a grid overlay image with line coordinates and tile labels.
993
+
994
+ The input image is expected to already be crop-local for a region, so grid
995
+ line coordinates are shown in the same rebased micrometer space used by the
996
+ transcript outputs.
997
+ """
998
+ if pixel_size_um is None or pixel_size_um <= 0:
999
+ raise ValueError("pixel_size_um must be > 0 for grid overlays")
1000
+ if tile_size_um <= 0:
1001
+ raise ValueError("tile_size_um must be > 0 for grid overlays")
1002
+
1003
+ base = _coerce_for_pillow(image)
1004
+ if base.ndim == 2:
1005
+ rgb = np.stack([base, base, base], axis=-1)
1006
+ elif base.ndim == 3 and base.shape[2] == 4:
1007
+ rgb = base[:, :, :3]
1008
+ elif base.ndim == 3 and base.shape[2] == 3:
1009
+ rgb = base
1010
+ else:
1011
+ raise ValueError(f"Unsupported image shape for grid overlay: {image.shape}")
1012
+
1013
+ rgb, downsample_scale = _resize_for_overlay(rgb, max_dimension_px=max_dimension_px)
1014
+ overlay_pixel_size_um = float(pixel_size_um) * downsample_scale
1015
+
1016
+ canvas = Image.fromarray(rgb)
1017
+ draw = ImageDraw.Draw(canvas)
1018
+ font = ImageFont.load_default()
1019
+
1020
+ height_px, width_px = rgb.shape[:2]
1021
+ width_um = width_px * overlay_pixel_size_um
1022
+ height_um = height_px * overlay_pixel_size_um
1023
+
1024
+ # Title block for quick inspection.
1025
+ draw.rectangle((6, 6, 320, 34), fill=(0, 0, 0))
1026
+ draw.text((10, 10), f"Level {level_name} grid {tile_size_um:.0f} um", fill=(255, 255, 255), font=font)
1027
+
1028
+ if fov_stride_um is not None:
1029
+ stride_x_um, stride_y_um = fov_stride_um
1030
+ if stride_x_um > 0:
1031
+ x_positions_um = np.arange(0.0, width_um + stride_x_um, stride_x_um, dtype=np.float64)
1032
+ for x_um in x_positions_um:
1033
+ x_px = int(round(x_um / overlay_pixel_size_um))
1034
+ if x_px < 0 or x_px >= width_px:
1035
+ continue
1036
+ draw.line((x_px, 0, x_px, height_px - 1), fill=(255, 0, 0), width=1)
1037
+ if stride_y_um > 0:
1038
+ y_positions_um = np.arange(0.0, height_um + stride_y_um, stride_y_um, dtype=np.float64)
1039
+ for y_um in y_positions_um:
1040
+ y_px = int(round(y_um / overlay_pixel_size_um))
1041
+ if y_px < 0 or y_px >= height_px:
1042
+ continue
1043
+ draw.line((0, y_px, width_px - 1, y_px), fill=(255, 0, 0), width=1)
1044
+
1045
+ if fov_stride_um is not None and fov_size_um is not None:
1046
+ stride_x_um, stride_y_um = fov_stride_um
1047
+ size_x_um, size_y_um = fov_size_um
1048
+ if stride_x_um > 0 and size_x_um > 0:
1049
+ x_end_positions_um = np.arange(size_x_um, width_um + size_x_um, stride_x_um, dtype=np.float64)
1050
+ for x_um in x_end_positions_um:
1051
+ x_px = int(round(x_um / overlay_pixel_size_um))
1052
+ if x_px < 0 or x_px >= width_px:
1053
+ continue
1054
+ draw.line((x_px, 0, x_px, height_px - 1), fill=(255, 96, 96), width=1)
1055
+ if stride_y_um > 0 and size_y_um > 0:
1056
+ y_end_positions_um = np.arange(size_y_um, height_um + size_y_um, stride_y_um, dtype=np.float64)
1057
+ for y_um in y_end_positions_um:
1058
+ y_px = int(round(y_um / overlay_pixel_size_um))
1059
+ if y_px < 0 or y_px >= height_px:
1060
+ continue
1061
+ draw.line((0, y_px, width_px - 1, y_px), fill=(255, 96, 96), width=1)
1062
+
1063
+ x_positions_um = np.arange(0.0, width_um + tile_size_um, tile_size_um, dtype=np.float64)
1064
+ y_positions_um = np.arange(0.0, height_um + tile_size_um, tile_size_um, dtype=np.float64)
1065
+
1066
+ for x_um in x_positions_um:
1067
+ x_px = int(round(x_um / overlay_pixel_size_um))
1068
+ if x_px < 0 or x_px >= width_px:
1069
+ continue
1070
+ draw.line((x_px, 0, x_px, height_px - 1), fill=(0, 255, 0), width=1)
1071
+ label = f"x={x_um:.0f}"
1072
+ text_y = 40 if (x_px // 40) % 2 == 0 else 56
1073
+ text_x = min(max(2, x_px + 2), max(2, width_px - 50))
1074
+ draw.rectangle((text_x - 1, text_y - 1, min(width_px - 1, text_x + 42), text_y + 10), fill=(0, 0, 0))
1075
+ draw.text((text_x, text_y), label, fill=(180, 255, 180), font=font)
1076
+
1077
+ for y_um in y_positions_um:
1078
+ y_px = int(round(y_um / overlay_pixel_size_um))
1079
+ if y_px < 0 or y_px >= height_px:
1080
+ continue
1081
+ draw.line((0, y_px, width_px - 1, y_px), fill=(0, 255, 0), width=1)
1082
+ label = f"y={y_um:.0f}"
1083
+ text_y = min(max(2, y_px + 2), max(2, height_px - 12))
1084
+ draw.rectangle((6, text_y - 1, 52, min(height_px - 1, text_y + 10)), fill=(0, 0, 0))
1085
+ draw.text((8, text_y), label, fill=(180, 255, 180), font=font)
1086
+
1087
+ tile_cols = max(1, int(np.ceil(width_um / tile_size_um)))
1088
+ tile_rows = max(1, int(np.ceil(height_um / tile_size_um)))
1089
+ for ty in range(tile_rows):
1090
+ for tx in range(tile_cols):
1091
+ cx_um = (tx + 0.5) * tile_size_um
1092
+ cy_um = (ty + 0.5) * tile_size_um
1093
+ cx_px = int(round(cx_um / overlay_pixel_size_um))
1094
+ cy_px = int(round(cy_um / overlay_pixel_size_um))
1095
+ if cx_px < 0 or cx_px >= width_px or cy_px < 0 or cy_px >= height_px:
1096
+ continue
1097
+ label = f"{tx},{ty}"
1098
+ text_w = 7 * len(label)
1099
+ left = min(max(0, cx_px - text_w // 2), max(0, width_px - text_w - 4))
1100
+ top = min(max(0, cy_px - 6), max(0, height_px - 14))
1101
+ draw.rectangle((left - 2, top - 2, min(width_px - 1, left + text_w + 2), min(height_px - 1, top + 10)), fill=(0, 0, 0))
1102
+ draw.text((left, top), label, fill=(180, 255, 180), font=font)
1103
+
1104
+ return np.asarray(canvas), overlay_pixel_size_um