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.
- xenium_splitter/__init__.py +6 -0
- xenium_splitter/cli.py +237 -0
- xenium_splitter/estimator.py +20 -0
- xenium_splitter/image_utils.py +1104 -0
- xenium_splitter/io_utils.py +3720 -0
- xenium_splitter/lasso.py +244 -0
- xenium_splitter/metadata.py +449 -0
- xenium_splitter/models.py +87 -0
- xenium_splitter/recalculate_diffexp.py +366 -0
- xenium_splitter/show_regions.py +253 -0
- xenium_splitter/splitter.py +3032 -0
- xenium_splitter/validate_transcripts_zarr.py +43 -0
- xenium_splitter-0.2.1.dist-info/METADATA +419 -0
- xenium_splitter-0.2.1.dist-info/RECORD +18 -0
- xenium_splitter-0.2.1.dist-info/WHEEL +5 -0
- xenium_splitter-0.2.1.dist-info/entry_points.txt +4 -0
- xenium_splitter-0.2.1.dist-info/licenses/LICENSE +21 -0
- xenium_splitter-0.2.1.dist-info/top_level.txt +1 -0
|
@@ -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
|