lavlab-shell 0.3.0__tar.gz → 0.3.2__tar.gz

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.
Files changed (39) hide show
  1. {lavlab_shell-0.3.0 → lavlab_shell-0.3.2}/PKG-INFO +1 -1
  2. {lavlab_shell-0.3.0 → lavlab_shell-0.3.2}/src/shell/__about__.py +1 -1
  3. {lavlab_shell-0.3.0 → lavlab_shell-0.3.2}/src/shell/cli.py +73 -24
  4. {lavlab_shell-0.3.0 → lavlab_shell-0.3.2}/src/shell/infer_omero_wsi.py +299 -65
  5. {lavlab_shell-0.3.0 → lavlab_shell-0.3.2}/src/shell/infer_wsi.py +21 -8
  6. {lavlab_shell-0.3.0 → lavlab_shell-0.3.2}/src/shell/inference.py +109 -8
  7. {lavlab_shell-0.3.0 → lavlab_shell-0.3.2}/src/shell/transforms.py +56 -54
  8. lavlab_shell-0.3.0/src/shell/preprocessing.py +0 -21
  9. {lavlab_shell-0.3.0 → lavlab_shell-0.3.2}/.devcontainer/Dockerfile +0 -0
  10. {lavlab_shell-0.3.0 → lavlab_shell-0.3.2}/.editorconfig +0 -0
  11. {lavlab_shell-0.3.0 → lavlab_shell-0.3.2}/.github/dependabot.yml +0 -0
  12. {lavlab_shell-0.3.0 → lavlab_shell-0.3.2}/.github/workflows/build.yml +0 -0
  13. {lavlab_shell-0.3.0 → lavlab_shell-0.3.2}/.github/workflows/lint.yml +0 -0
  14. {lavlab_shell-0.3.0 → lavlab_shell-0.3.2}/.github/workflows/publish.yml +0 -0
  15. {lavlab_shell-0.3.0 → lavlab_shell-0.3.2}/.github/workflows/pytest.yml +0 -0
  16. {lavlab_shell-0.3.0 → lavlab_shell-0.3.2}/.gitignore +0 -0
  17. {lavlab_shell-0.3.0 → lavlab_shell-0.3.2}/.pre-commit-config.yaml +0 -0
  18. {lavlab_shell-0.3.0 → lavlab_shell-0.3.2}/CONTRIBUTING.md +0 -0
  19. {lavlab_shell-0.3.0 → lavlab_shell-0.3.2}/Dockerfile +0 -0
  20. {lavlab_shell-0.3.0 → lavlab_shell-0.3.2}/LICENSE.txt +0 -0
  21. {lavlab_shell-0.3.0 → lavlab_shell-0.3.2}/Makefile +0 -0
  22. {lavlab_shell-0.3.0 → lavlab_shell-0.3.2}/README.md +0 -0
  23. {lavlab_shell-0.3.0 → lavlab_shell-0.3.2}/docs/api.md +0 -0
  24. {lavlab_shell-0.3.0 → lavlab_shell-0.3.2}/docs/index.md +0 -0
  25. {lavlab_shell-0.3.0 → lavlab_shell-0.3.2}/mkdocs.yml +0 -0
  26. {lavlab_shell-0.3.0 → lavlab_shell-0.3.2}/pyproject.toml +0 -0
  27. {lavlab_shell-0.3.0 → lavlab_shell-0.3.2}/requirements/requirements-docs.txt +0 -0
  28. {lavlab_shell-0.3.0 → lavlab_shell-0.3.2}/requirements/requirements-lint.txt +0 -0
  29. {lavlab_shell-0.3.0 → lavlab_shell-0.3.2}/requirements/requirements-test.txt +0 -0
  30. {lavlab_shell-0.3.0 → lavlab_shell-0.3.2}/requirements/requirements-types.txt +0 -0
  31. {lavlab_shell-0.3.0 → lavlab_shell-0.3.2}/requirements.txt +0 -0
  32. {lavlab_shell-0.3.0 → lavlab_shell-0.3.2}/src/shell/__init__.py +0 -0
  33. {lavlab_shell-0.3.0 → lavlab_shell-0.3.2}/src/shell/benchmark.py +0 -0
  34. {lavlab_shell-0.3.0 → lavlab_shell-0.3.2}/src/shell/model.py +0 -0
  35. {lavlab_shell-0.3.0 → lavlab_shell-0.3.2}/src/shell/py.typed +0 -0
  36. {lavlab_shell-0.3.0 → lavlab_shell-0.3.2}/src/shell/weights/model_v1.pth +0 -0
  37. {lavlab_shell-0.3.0 → lavlab_shell-0.3.2}/tests/__init__.py +0 -0
  38. {lavlab_shell-0.3.0 → lavlab_shell-0.3.2}/tests/conftest.py +0 -0
  39. {lavlab_shell-0.3.0 → lavlab_shell-0.3.2}/tests/test_shell.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lavlab-shell
3
- Version: 0.3.0
3
+ Version: 0.3.2
4
4
  Summary: SHELL Highlights Epithelium and Lumen Locations — whole-slide H&E segmentation
5
5
  Project-URL: Documentation, https://github.com/laviolette-lab/shell#readme
6
6
  Project-URL: Issues, https://github.com/laviolette-lab/shell/issues
@@ -3,4 +3,4 @@
3
3
  # SPDX-License-Identifier: MIT
4
4
  """Version information for shell."""
5
5
 
6
- __version__ = "0.3.0"
6
+ __version__ = "0.3.2"
@@ -12,6 +12,30 @@ Provides two subcommands:
12
12
 
13
13
  from __future__ import annotations
14
14
 
15
+ import os
16
+
17
+ # Ensure PyTorch initialises its runtime and threads before other native
18
+ # libraries (pyvips, Ice/OMERO) to avoid macOS-level races that can lead
19
+ # to segmentation faults when different C extensions compete for thread
20
+ # runtime ownership. Set conservative thread counts before importing torch.
21
+ os.environ.setdefault("OMP_NUM_THREADS", "1")
22
+ os.environ.setdefault("OPENBLAS_NUM_THREADS", "1")
23
+ os.environ.setdefault("MKL_NUM_THREADS", "1")
24
+ os.environ.setdefault("VECLIB_MAXIMUM_THREADS", "1")
25
+
26
+ try:
27
+ # Import torch early so it initialises its native runtime in the main
28
+ # thread. This is intentional — we only probe __version__ to force
29
+ # initialisation without performing heavy work here.
30
+ import torch # type: ignore
31
+
32
+ _ = torch.__version__ # ensure attribute access triggers initialisation
33
+ TORCH_AVAILABLE = True
34
+ except Exception:
35
+ # If torch is unavailable we still continue — the program will emit
36
+ # a clearer error later when trying to build/load the model.
37
+ TORCH_AVAILABLE = False
38
+
15
39
  import argparse
16
40
  import getpass
17
41
  import logging
@@ -19,6 +43,9 @@ import sys
19
43
 
20
44
  from shell.__about__ import __version__
21
45
 
46
+ # Module logger — used for diagnostic messages instead of printing directly.
47
+ log = logging.getLogger(__name__)
48
+
22
49
 
23
50
  def build_parser() -> argparse.ArgumentParser:
24
51
  """Build and return the argument parser."""
@@ -287,35 +314,57 @@ def main(argv: list[str] | None = None) -> int:
287
314
  return 0
288
315
 
289
316
  if args.command == "infer-omero":
317
+ # Diagnostic prints added to help narrow where a macOS segfault occurs.
318
+ # These prints are intentionally lightweight and flushed so they appear
319
+ # even if the process crashes in a C extension later on.
320
+ log.debug("DIAG: infer-omero selected")
321
+ log.debug("DIAG: about to import infer_omero_wsi")
290
322
  from shell.infer_omero_wsi import infer_omero_wsi
291
323
 
324
+ log.debug("DIAG: imported infer_omero_wsi")
325
+
292
326
  if args.password is None:
293
327
  args.password = getpass.getpass("OMERO password: ")
294
328
 
295
- print(f"Connecting to {args.host}:{args.port} …")
296
- infer_omero_wsi(
297
- host=args.host,
298
- port=args.port,
299
- username=args.username,
300
- password=args.password,
301
- image_id=args.image_id,
302
- model_path=args.model_path,
303
- model_version=args.model_version,
304
- output_path=args.output,
305
- target_mpp=args.target_mpp,
306
- group_id=args.group_id,
307
- save_eho=args.save_eho,
308
- no_tissue_crop=args.no_tissue_crop,
309
- device=args.device,
310
- inference_tile_size=args.inference_tile_size,
311
- tile_overlap=args.tile_overlap,
312
- sw_overlap=args.sw_overlap,
313
- num_bg_tiles=args.num_bg_tiles,
314
- min_thumb_size=args.min_thumb_size,
315
- min_tissue_frac=args.min_tissue_frac,
316
- prefetch_depth=args.prefetch_depth,
317
- num_fetch_workers=args.fetch_workers,
318
- )
329
+ print(f"Connecting to {args.host}:{args.port} …", flush=True)
330
+ log.debug("DIAG: about to call infer_omero_wsi")
331
+ try:
332
+ infer_omero_wsi(
333
+ host=args.host,
334
+ port=args.port,
335
+ username=args.username,
336
+ password=args.password,
337
+ image_id=args.image_id,
338
+ model_path=args.model_path,
339
+ model_version=args.model_version,
340
+ output_path=args.output,
341
+ target_mpp=args.target_mpp,
342
+ group_id=args.group_id,
343
+ save_eho=args.save_eho,
344
+ no_tissue_crop=args.no_tissue_crop,
345
+ device=args.device,
346
+ inference_tile_size=args.inference_tile_size,
347
+ tile_overlap=args.tile_overlap,
348
+ sw_overlap=args.sw_overlap,
349
+ num_bg_tiles=args.num_bg_tiles,
350
+ min_thumb_size=args.min_thumb_size,
351
+ min_tissue_frac=args.min_tissue_frac,
352
+ prefetch_depth=args.prefetch_depth,
353
+ num_fetch_workers=args.fetch_workers,
354
+ )
355
+ log.debug("DIAG: infer_omero_wsi returned normally")
356
+ except BaseException as e:
357
+ # Catch and print Python-level exceptions; note segfaults (native crashes)
358
+ # will not be caught here, but these diagnostics will show progress up to
359
+ # the crash point.
360
+ import sys
361
+ import traceback
362
+
363
+ log.debug("DIAG: infer_omero_wsi raised an exception:")
364
+ traceback.print_exc()
365
+ # Re-raise so existing behaviour (non-zero exit) remains.
366
+ raise
367
+ log.debug("DIAG: after infer_omero_wsi (should have saved output)")
319
368
  print(f"Saved prediction to {args.output}")
320
369
  return 0
321
370
 
@@ -48,28 +48,66 @@ logger = logging.getLogger(__name__)
48
48
  # ---------------------------------------------------------------------------
49
49
  # Optional OMERO dependency gate
50
50
  # ---------------------------------------------------------------------------
51
- try:
52
- from omero.gateway import BlitzGateway as _BlitzGateway
53
- from omero.model import enums as omero_enums
54
-
55
- PIXEL_TYPES: dict[str, type] = {
56
- omero_enums.PixelsTypeint8: np.int8,
57
- omero_enums.PixelsTypeuint8: np.uint8,
58
- omero_enums.PixelsTypeint16: np.int16,
59
- omero_enums.PixelsTypeuint16: np.uint16,
60
- omero_enums.PixelsTypeint32: np.int32,
61
- omero_enums.PixelsTypeuint32: np.uint32,
62
- omero_enums.PixelsTypefloat: np.float32,
63
- omero_enums.PixelsTypedouble: np.float64,
64
- }
65
- _HAS_OMERO = True
66
- except ImportError:
67
- _HAS_OMERO = False
68
- PIXEL_TYPES = {}
51
+ # Lazy OMERO loader: defer importing the heavy omero modules until runtime.
52
+ # This avoids importing OMERO (and its C extensions) at module import time
53
+ # which can race with other libraries (e.g. torch / pyvips) and cause
54
+ # platform-specific crashes.
55
+ _OMERO_BG_CLASS = None
56
+ _OMERO_LOADED = False
57
+ PIXEL_TYPES: dict = {}
58
+
59
+
60
+ def _ensure_omero() -> None:
61
+ """Attempt to import OMERO & populate `PIXEL_TYPES` / `_OMERO_BG_CLASS`.
62
+
63
+ This is idempotent and safe to call from any runtime location. If the
64
+ import fails the function leaves `_OMERO_LOADED` as False.
65
+ """
66
+ global _OMERO_BG_CLASS, PIXEL_TYPES, _OMERO_LOADED
67
+ if _OMERO_LOADED:
68
+ return
69
+ try:
70
+ import omero # noqa: F401 (import to ensure runtime available)
71
+ from omero.gateway import BlitzGateway as _BG
72
+ from omero.model import enums as omero_enums
73
+
74
+ # Map likely enum attribute names to numpy dtypes, but use getattr
75
+ # so code is robust to differences across OMERO versions.
76
+ _mapping = {
77
+ "PixelsTypeint8": np.int8,
78
+ "PixelsTypeuint8": np.uint8,
79
+ "PixelsTypeint16": np.int16,
80
+ "PixelsTypeuint16": np.uint16,
81
+ "PixelsTypeint32": np.int32,
82
+ "PixelsTypeuint32": np.uint32,
83
+ "PixelsTypefloat": np.float32,
84
+ "PixelsTypedouble": np.float64,
85
+ }
86
+
87
+ pix = {}
88
+ for name, dtype in _mapping.items():
89
+ enum_val = getattr(omero_enums, name, None)
90
+ if enum_val is not None:
91
+ pix[enum_val] = dtype
92
+
93
+ PIXEL_TYPES.clear()
94
+ PIXEL_TYPES.update(pix)
95
+
96
+ _OMERO_BG_CLASS = _BG
97
+ _OMERO_LOADED = True
98
+ except Exception:
99
+ # Leave PIXEL_TYPES empty and _OMERO_LOADED False on failure.
100
+ PIXEL_TYPES.clear()
101
+ _OMERO_BG_CLASS = None
102
+ _OMERO_LOADED = False
69
103
 
70
104
 
71
105
  def _require_omero() -> None:
72
- if not _HAS_OMERO:
106
+ # Ensure the OMERO runtime is available at call time. This calls the
107
+ # lazy loader which attempts to import OMERO and populate the runtime
108
+ # objects. If import fails we raise the usual ImportError.
109
+ _ensure_omero()
110
+ if not _OMERO_LOADED:
73
111
  msg = (
74
112
  "The 'omero' optional dependency is required for OMERO support. "
75
113
  "Install it with: pip install shell[omero]"
@@ -142,10 +180,25 @@ def create_omero_connection(
142
180
  A connected gateway instance.
143
181
  """
144
182
  _require_omero()
145
- from omero.gateway import BlitzGateway as _BG
183
+ # Use the lazily-loaded BlitzGateway class populated by _ensure_omero().
184
+ _BG = _OMERO_BG_CLASS
185
+ if _BG is None:
186
+ # Defensive: if the class is not available treat as missing dependency.
187
+ raise ImportError(
188
+ "OMERO BlitzGateway class not available; ensure OMERO is installed"
189
+ )
146
190
 
147
191
  parsed = urlparse(host)
148
192
 
193
+ # Diagnostic print to help trace macOS crashes during connection setup.
194
+ try:
195
+ logger.debug(
196
+ f"DIAG: create_omero_connection start host={host!r} port={port} scheme={parsed.scheme!r}"
197
+ )
198
+ except Exception:
199
+ # Best-effort diagnostic; do not stop the connection logic if logging fails.
200
+ pass
201
+
149
202
  if parsed.scheme in ("wss", "ws"):
150
203
  import omero # type: ignore[import-untyped]
151
204
 
@@ -166,9 +219,20 @@ def create_omero_connection(
166
219
  ws_path,
167
220
  )
168
221
 
222
+ try:
223
+ logger.debug(
224
+ f"DIAG: creating omero.client with router={router!r} (proto={proto})"
225
+ )
226
+ except Exception:
227
+ pass
228
+
169
229
  client = omero.client(args=["--Ice.Default.Router=" + router])
170
230
  try:
171
231
  client.createSession(username, password)
232
+ try:
233
+ logger.debug("DIAG: omero.client.createSession succeeded")
234
+ except Exception:
235
+ pass
172
236
  except Exception as exc:
173
237
  msg = (
174
238
  f"Failed to create OMERO session via "
@@ -177,18 +241,51 @@ def create_omero_connection(
177
241
  raise RuntimeError(msg) from exc
178
242
 
179
243
  conn = _BG(client_obj=client)
180
- if not conn.connect():
244
+ # If we created a raw omero.client earlier (websocket path) attach it
245
+ # to the BlitzGateway instance so it can be closed explicitly later.
246
+ try:
247
+ setattr(conn, "_omero_ws_client", client)
248
+ except Exception:
249
+ pass
250
+ try:
251
+ connected = conn.connect()
252
+ try:
253
+ logger.debug(f"DIAG: BlitzGateway.connect() returned {connected!r}")
254
+ except Exception:
255
+ pass
256
+ except Exception as exc:
257
+ # Surface the connect() error with a diagnostic print before raising.
258
+ try:
259
+ logger.debug(f"DIAG: BlitzGateway.connect() raised: {exc}")
260
+ except Exception:
261
+ pass
262
+ raise
263
+
264
+ if not connected:
181
265
  msg = (
182
266
  f"BlitzGateway.connect() failed after session creation "
183
267
  f"({proto}://{ws_host}:{ws_port}{ws_path})"
184
268
  )
185
269
  raise RuntimeError(msg)
270
+ try:
271
+ logger.debug(
272
+ "DIAG: create_omero_connection returning connected WS BlitzGateway"
273
+ )
274
+ except Exception:
275
+ pass
186
276
  return conn
187
277
 
188
278
  # Standard Ice connection
189
279
  clean_host = parsed.hostname or host
190
280
  actual_port = parsed.port or port
191
281
 
282
+ try:
283
+ logger.debug(
284
+ f"DIAG: creating standard BlitzGateway to {clean_host}:{actual_port} secure={secure}"
285
+ )
286
+ except Exception:
287
+ pass
288
+
192
289
  conn = _BG(
193
290
  username,
194
291
  password,
@@ -196,12 +293,75 @@ def create_omero_connection(
196
293
  port=actual_port,
197
294
  secure=secure,
198
295
  )
199
- if not conn.connect():
296
+ try:
297
+ connected = conn.connect()
298
+ try:
299
+ logger.debug(f"DIAG: BlitzGateway.connect() returned {connected!r}")
300
+ except Exception:
301
+ pass
302
+ except Exception as exc:
303
+ try:
304
+ logger.debug(f"DIAG: BlitzGateway.connect() raised: {exc}")
305
+ except Exception:
306
+ pass
307
+ raise
308
+
309
+ if not connected:
200
310
  msg = f"Failed to connect to OMERO at {clean_host}:{actual_port}"
201
311
  raise RuntimeError(msg)
312
+
313
+ try:
314
+ logger.debug("DIAG: create_omero_connection returning connected BlitzGateway")
315
+ except Exception:
316
+ pass
202
317
  return conn
203
318
 
204
319
 
320
+ def _safe_close_blitzgateway(conn: Any) -> None:
321
+ """Best-effort close of a BlitzGateway and any attached raw omero.client.
322
+
323
+ Different OMERO transports / versions expose slightly different cleanup
324
+ APIs. Try several likely methods without raising to ensure Ice
325
+ communicators are destroyed where possible.
326
+ """
327
+ try:
328
+ # Preferred: BlitzGateway.close()
329
+ if hasattr(conn, "close"):
330
+ try:
331
+ conn.close()
332
+ except Exception:
333
+ pass
334
+ except Exception:
335
+ pass
336
+
337
+ try:
338
+ # Some versions expose disconnect()
339
+ if hasattr(conn, "disconnect"):
340
+ try:
341
+ conn.disconnect()
342
+ except Exception:
343
+ pass
344
+ except Exception:
345
+ pass
346
+
347
+ try:
348
+ # If we attached a raw omero.client during creation, try to close/destroy it.
349
+ client_obj = getattr(conn, "_omero_ws_client", None)
350
+ if client_obj is not None:
351
+ if hasattr(client_obj, "close"):
352
+ try:
353
+ client_obj.close()
354
+ except Exception:
355
+ pass
356
+ if hasattr(client_obj, "destroy"):
357
+ try:
358
+ client_obj.destroy()
359
+ except Exception:
360
+ pass
361
+ except Exception:
362
+ pass
363
+
364
+
205
365
  # ---------------------------------------------------------------------------
206
366
  # Pixel helpers
207
367
  # ---------------------------------------------------------------------------
@@ -622,7 +782,7 @@ def _fetch_thumbnail_rgb(
622
782
 
623
783
  def _build_tissue_mask(thumb_rgb: np.ndarray) -> np.ndarray:
624
784
  """Return a boolean tissue mask (``True`` = tissue) from a thumbnail."""
625
- from shell.preprocessing import detect_background
785
+ from shell.transforms import detect_background
626
786
 
627
787
  bg_mask = detect_background(thumb_rgb)
628
788
  return ~bg_mask
@@ -856,10 +1016,7 @@ def _precompute_tile_info(
856
1016
 
857
1017
  def _fetch_worker(
858
1018
  worker_id: int,
859
- host: str,
860
- port: int,
861
- username: str,
862
- password: str,
1019
+ conn: "BlitzGateway",
863
1020
  work_queue: queue.Queue[Any],
864
1021
  result_queue: queue.Queue[Any],
865
1022
  stop_event: threading.Event,
@@ -875,7 +1032,13 @@ def _fetch_worker(
875
1032
  sy: float,
876
1033
  stain_params: dict[str, Any],
877
1034
  ) -> None:
878
- """Single fetch worker — owns its own OMERO connection.
1035
+ """Single fetch worker — uses a BlitzGateway connection provided by the
1036
+ main thread.
1037
+
1038
+ The main thread is responsible for creating the OMERO connections and
1039
+ passing a connection object to each worker. Workers must NOT close the
1040
+ shared connection; the coordinator will close connections after workers
1041
+ exit.
879
1042
 
880
1043
  Pulls tile descriptors from *work_queue*, fetches pixel data from
881
1044
  OMERO (using the bulk-fetch fast-path when possible), applies EHO
@@ -884,12 +1047,12 @@ def _fetch_worker(
884
1047
  The bounded *result_queue* provides back-pressure: if inference falls
885
1048
  behind, workers block on ``put`` until space is available.
886
1049
  """
887
- from shell.preprocessing import apply_eho_chunked
1050
+ from shell.transforms import apply_eho_chunked
888
1051
 
889
- conn = None
1052
+ # Connection is provided by the caller (created in the main/coordinator
1053
+ # thread). Worker owns a RawPixelsStore instance derived from it.
890
1054
  store = None
891
1055
  try:
892
- conn = create_omero_connection(host, port, username, password)
893
1056
  store = conn.c.sf.createRawPixelsStore()
894
1057
  store.setPixelsId(pixels_id, False)
895
1058
  store.setResolutionLevel(best_level)
@@ -952,16 +1115,14 @@ def _fetch_worker(
952
1115
  logger.exception("Fetch worker %d encountered an error", worker_id)
953
1116
  result_queue.put(exc)
954
1117
  finally:
1118
+ # Close only the RawPixelsStore opened by this worker.
955
1119
  if store is not None:
956
1120
  try:
957
1121
  store.close()
958
1122
  except Exception:
959
1123
  pass
960
- if conn is not None:
961
- try:
962
- conn.close()
963
- except Exception:
964
- pass
1124
+ # Do NOT close the BlitzGateway connection here; the coordinator
1125
+ # (creator) is responsible for closing it.
965
1126
 
966
1127
 
967
1128
  def _parallel_fetch_coordinator(
@@ -986,10 +1147,10 @@ def _parallel_fetch_coordinator(
986
1147
  ) -> None:
987
1148
  """Start *num_workers* fetch threads, each with its own OMERO session.
988
1149
 
989
- All tile descriptors are placed on a shared work queue. Workers pull
990
- tiles, fetch pixels, apply EHO, and place results on *result_queue*.
991
- When the work queue is exhausted a ``_SENTINEL`` is placed on
992
- *result_queue* so the consumer knows all tiles have been produced.
1150
+ The coordinator now creates the BlitzGateway connections in the main
1151
+ coordinator thread and passes a dedicated connection to each worker.
1152
+ This avoids importing / initialising the OMERO native runtime inside
1153
+ worker threads (which can trigger macOS-level races and segfaults).
993
1154
  """
994
1155
  work_queue: queue.Queue[Any] = queue.Queue()
995
1156
  stop_event = threading.Event()
@@ -1006,16 +1167,31 @@ def _parallel_fetch_coordinator(
1006
1167
  n_tiles,
1007
1168
  )
1008
1169
 
1170
+ # Pre-create one BlitzGateway connection per worker in this (main)
1171
+ # coordinator thread to avoid initialising OMERO / Ice inside workers.
1172
+ conns: list[Any] = []
1173
+ for i in range(num_workers):
1174
+ try:
1175
+ conn = create_omero_connection(host, port, username, password)
1176
+ conns.append(conn)
1177
+ logger.debug("Created OMERO connection for worker %d", i)
1178
+ except Exception as exc:
1179
+ # Clean up any already-created connections and re-raise.
1180
+ for c in conns:
1181
+ try:
1182
+ c.close()
1183
+ except Exception:
1184
+ pass
1185
+ logger.exception("Failed to create OMERO connection for worker %d", i)
1186
+ raise
1187
+
1009
1188
  workers: list[threading.Thread] = []
1010
1189
  for i in range(num_workers):
1011
1190
  t = threading.Thread(
1012
1191
  target=_fetch_worker,
1013
1192
  kwargs={
1014
1193
  "worker_id": i,
1015
- "host": host,
1016
- "port": port,
1017
- "username": username,
1018
- "password": password,
1194
+ "conn": conns[i],
1019
1195
  "work_queue": work_queue,
1020
1196
  "result_queue": result_queue,
1021
1197
  "stop_event": stop_event,
@@ -1043,6 +1219,14 @@ def _parallel_fetch_coordinator(
1043
1219
  result_queue.put(_SENTINEL)
1044
1220
  logger.info("All fetch workers finished.")
1045
1221
 
1222
+ # Close all pre-created BlitzGateway connections used by the workers.
1223
+ # Use the best-effort helper to avoid leaving Ice communicators open.
1224
+ for c in conns:
1225
+ try:
1226
+ _safe_close_blitzgateway(c)
1227
+ except Exception:
1228
+ pass
1229
+
1046
1230
 
1047
1231
  # ---------------------------------------------------------------------------
1048
1232
  # Save helpers
@@ -1186,8 +1370,19 @@ def infer_omero_wsi(
1186
1370
  np.ndarray
1187
1371
  ``(H, W)`` uint8 label map.
1188
1372
  """
1189
- _require_omero()
1373
+ try:
1374
+ logger.debug(
1375
+ f"DIAG: infer_omero_wsi start host={host!r} port={port} image_id={image_id} "
1376
+ f"inference_tile_size={inference_tile_size} num_fetch_workers={num_fetch_workers}"
1377
+ )
1378
+ except Exception:
1379
+ pass
1190
1380
 
1381
+ _require_omero()
1382
+ try:
1383
+ logger.debug("DIAG: _require_omero() returned")
1384
+ except Exception:
1385
+ pass
1191
1386
  # Sanity-check: overlap must leave room for a meaningful core.
1192
1387
  if tile_overlap < 0:
1193
1388
  tile_overlap = 0
@@ -1200,6 +1395,36 @@ def infer_omero_wsi(
1200
1395
  )
1201
1396
  tile_overlap = inference_tile_size // 4
1202
1397
 
1398
+ # Ensure PyTorch and the model are initialised in the main thread BEFORE
1399
+ # creating OMERO connections / starting any worker threads. This avoids
1400
+ # macOS-level races between native runtimes (torch / libvips / Ice).
1401
+ try:
1402
+ import torch # type: ignore
1403
+
1404
+ from shell.model import build_model
1405
+ except Exception:
1406
+ # If torch or model scaffolding is not available we'll let the
1407
+ # existing _require_omero() / create_omero_connection surface an
1408
+ # appropriate error later. Continue to attempt OMERO connection.
1409
+ torch = None # type: ignore
1410
+ build_model = None # type: ignore
1411
+
1412
+ if build_model is not None:
1413
+ if device == "auto":
1414
+ try:
1415
+ if torch is not None and torch.cuda.is_available():
1416
+ device = "cuda"
1417
+ elif torch is not None and torch.backends.mps.is_available():
1418
+ device = "mps"
1419
+ else:
1420
+ device = "cpu"
1421
+ except Exception:
1422
+ device = "cpu"
1423
+ logger.info("Loading model (device=%s) …", device)
1424
+ model = build_model(model_path, device, model_version=model_version)
1425
+ else:
1426
+ model = None # type: ignore
1427
+
1203
1428
  conn = create_omero_connection(host, port, username, password)
1204
1429
 
1205
1430
  try:
@@ -1316,7 +1541,7 @@ def infer_omero_wsi(
1316
1541
  # ------------------------------------------------------------------
1317
1542
  # 3. Fetch a few background tiles at full resolution for Io
1318
1543
  # ------------------------------------------------------------------
1319
- from shell.preprocessing import compute_background_intensity
1544
+ from shell.transforms import compute_background_intensity
1320
1545
 
1321
1546
  bg_tile_coords = _pick_background_tile_coords(
1322
1547
  tissue_mask,
@@ -1459,19 +1684,19 @@ def infer_omero_wsi(
1459
1684
  out_h=out_h,
1460
1685
  out_w=out_w,
1461
1686
  prefetch_depth=prefetch_depth,
1462
- model_path=model_path,
1463
- model_version=model_version,
1687
+ model=model,
1464
1688
  device=device,
1465
1689
  save_eho=save_eho,
1466
1690
  output_path=output_path,
1467
1691
  tile_overlap=tile_overlap,
1692
+ roi_size=inference_tile_size,
1468
1693
  sw_overlap=sw_overlap,
1469
1694
  num_fetch_workers=num_fetch_workers,
1470
1695
  )
1471
1696
 
1472
1697
  except Exception:
1473
1698
  try:
1474
- conn.close()
1699
+ _safe_close_blitzgateway(conn)
1475
1700
  except Exception:
1476
1701
  pass
1477
1702
  raise
@@ -1507,12 +1732,12 @@ def _run_pipeline(
1507
1732
  out_h: int,
1508
1733
  out_w: int,
1509
1734
  prefetch_depth: int,
1510
- model_path: str | None,
1511
- model_version: str | None,
1735
+ model: Any,
1512
1736
  device: str,
1513
1737
  save_eho: str | None,
1514
1738
  output_path: str,
1515
1739
  tile_overlap: int,
1740
+ roi_size: int,
1516
1741
  sw_overlap: float,
1517
1742
  num_fetch_workers: int = 4,
1518
1743
  ) -> np.ndarray:
@@ -1566,26 +1791,34 @@ def _run_pipeline(
1566
1791
  name="omero-fetch-coordinator",
1567
1792
  daemon=True,
1568
1793
  )
1569
- fetch_thread.start()
1570
-
1571
1794
  try:
1572
- # torch is imported ONLY here, in the main thread. The fetch
1573
- # thread never touches torch.
1574
- import torch
1795
+ # torch is imported here for device checks used by run_inference.
1796
+ # The model itself is provided by the caller (loaded earlier in the
1797
+ # main infer_omero_wsi function) and must be passed via the `model`
1798
+ # parameter. Starting the fetch thread now is safe because the model
1799
+ # has already been initialised on the main thread.
1800
+ import torch # type: ignore
1575
1801
 
1576
1802
  from shell.inference import run_inference
1577
- from shell.model import build_model
1578
1803
 
1579
1804
  if device == "auto":
1580
- if torch.cuda.is_available():
1581
- device = "cuda"
1582
- elif torch.backends.mps.is_available():
1583
- device = "mps"
1584
- else:
1805
+ try:
1806
+ if torch.cuda.is_available():
1807
+ device = "cuda"
1808
+ elif torch.backends.mps.is_available():
1809
+ device = "mps"
1810
+ else:
1811
+ device = "cpu"
1812
+ except Exception:
1585
1813
  device = "cpu"
1586
1814
 
1587
- logger.info("Loading model (device=%s) …", device)
1588
- model = build_model(model_path, device, model_version=model_version)
1815
+ # Start the fetch thread immediately; the heavy model runtime was
1816
+ # already initialised by the caller before calling _run_pipeline.
1817
+ try:
1818
+ fetch_thread.start()
1819
+ logger.info("Started OMERO fetch thread.")
1820
+ except Exception:
1821
+ logger.exception("Failed to start OMERO fetch thread; continuing.")
1589
1822
 
1590
1823
  pred = np.zeros((out_h, out_w), dtype=np.uint8)
1591
1824
 
@@ -1611,6 +1844,7 @@ def _run_pipeline(
1611
1844
  tile_eho,
1612
1845
  model,
1613
1846
  device,
1847
+ roi_size=(roi_size, roi_size),
1614
1848
  overlap=sw_overlap,
1615
1849
  )
1616
1850
  del tile_eho
@@ -1649,6 +1883,6 @@ def _run_pipeline(
1649
1883
  if fetch_thread.is_alive():
1650
1884
  logger.warning("Fetch thread did not exit within 30 s; continuing cleanup.")
1651
1885
  try:
1652
- conn.close()
1886
+ _safe_close_blitzgateway(conn)
1653
1887
  except Exception:
1654
1888
  pass
@@ -24,6 +24,7 @@ import logging
24
24
  import os
25
25
  import warnings
26
26
  from types import ModuleType
27
+ from typing import cast
27
28
 
28
29
  import numpy as np
29
30
 
@@ -147,8 +148,18 @@ def _load_image(
147
148
  raise ValueError(msg) from exc
148
149
 
149
150
 
150
- def _vips_to_rgb_numpy(vips_img: pyvips.Image) -> np.ndarray:
151
- """Convert a pyvips image to (H, W, 3) uint8 RGB numpy array."""
151
+ def _vips_to_rgb_numpy(vips_img: pyvips.Image | np.ndarray) -> np.ndarray:
152
+ """Convert a pyvips image or numpy array to (H, W, 3) uint8 RGB numpy array."""
153
+ # If the caller accidentally passes a numpy array (e.g. from an openslide
154
+ # fallback), accept it and normalise to (H, W, 3) uint8.
155
+ if isinstance(vips_img, np.ndarray):
156
+ arr = vips_img
157
+ if arr.ndim == 2:
158
+ arr = np.stack([arr, arr, arr], axis=-1)
159
+ elif arr.ndim == 3 and arr.shape[2] == 4:
160
+ arr = arr[..., :3]
161
+ return arr.astype(np.uint8)
162
+
152
163
  bands = vips_img.bands
153
164
  if bands == 1:
154
165
  vips_img = vips_img.bandjoin([vips_img, vips_img])
@@ -251,11 +262,11 @@ def preprocess_wsi(
251
262
  needs_scaling = not (abs(scale_x - 1.0) < 1e-6 and abs(scale_y - 1.0) < 1e-6)
252
263
 
253
264
  if source == "vips":
254
- vips_img = img_or_vips
265
+ vips_img = cast(pyvips.Image, img_or_vips)
255
266
  if needs_scaling:
256
- vips_img = vips_img.resize(
257
- 1.0 / scale_x, vscale=1.0 / scale_y, kernel="lanczos3"
258
- )
267
+ # Use positional vscale/kernel to satisfy the pyvips stubs and
268
+ # avoid type-checker complaints about keyword-only overloads.
269
+ vips_img = vips_img.resize(1.0 / scale_x, 1.0 / scale_y, "lanczos3")
259
270
  image_np = _vips_to_rgb_numpy(vips_img)
260
271
  del vips_img
261
272
  else:
@@ -263,8 +274,10 @@ def preprocess_wsi(
263
274
  image_np = img_or_vips
264
275
  if needs_scaling:
265
276
  vips_tmp = pyvips.Image.new_from_array(image_np)
266
- vips_tmp = vips_tmp.resize(
267
- 1.0 / scale_x, vscale=1.0 / scale_y, kernel="lanczos3"
277
+ # cast to Image for the type-checker and use positional args for
278
+ # the same reason as above.
279
+ vips_tmp = cast(pyvips.Image, vips_tmp).resize(
280
+ 1.0 / scale_x, 1.0 / scale_y, "lanczos3"
268
281
  )
269
282
  image_np = _vips_to_rgb_numpy(vips_tmp)
270
283
  del vips_tmp
@@ -21,6 +21,7 @@ in the output, saving downstream processing.
21
21
  from __future__ import annotations
22
22
 
23
23
  import gc
24
+ import os
24
25
  import warnings
25
26
 
26
27
  import numpy as np
@@ -39,7 +40,7 @@ from torch.amp import autocast
39
40
  # ---------------------------------------------------------------------------
40
41
  # Default inference parameters
41
42
  # ---------------------------------------------------------------------------
42
- VAL_ROI_SIZE: tuple[int, int] = (512, 512)
43
+ VAL_ROI_SIZE: tuple[int, int] = (2048, 2048)
43
44
  VAL_SW_BATCH: int = 16
44
45
  VAL_SW_OVERLAP: float = 0.25
45
46
 
@@ -63,9 +64,7 @@ def _build_normalize_pipeline() -> Compose:
63
64
  b_max=1.0,
64
65
  clip=True,
65
66
  ),
66
- NormalizeIntensityd(
67
- keys="image", nonzero=True, channel_wise=True
68
- ),
67
+ NormalizeIntensityd(keys="image", nonzero=True, channel_wise=True),
69
68
  ScaleIntensityd(keys=["image"]),
70
69
  ]
71
70
  )
@@ -91,7 +90,7 @@ def run_inference(
91
90
  device: torch.device | str = "cpu",
92
91
  *,
93
92
  roi_size: tuple[int, int] = VAL_ROI_SIZE,
94
- sw_batch_size: int = VAL_SW_BATCH,
93
+ sw_batch_size: int | None = None,
95
94
  overlap: float = VAL_SW_OVERLAP,
96
95
  tissue_mask: np.ndarray | None = None,
97
96
  ) -> np.ndarray:
@@ -101,7 +100,7 @@ def run_inference(
101
100
  :param model: trained SegResNetVAE in eval mode.
102
101
  :param device: computation device.
103
102
  :param roi_size: sliding-window patch size.
104
- :param sw_batch_size: number of patches per forward pass.
103
+ :param sw_batch_size: number of patches per forward pass (None => auto).
105
104
  :param overlap: fraction of overlap between sliding-window patches.
106
105
  Values > 0 enable Gaussian importance weighting so that
107
106
  overlapping patch centres contribute more than edges, which
@@ -130,6 +129,53 @@ def run_inference(
130
129
  if pad_h or pad_w:
131
130
  img_t = F.pad(img_t, padding, "reflect")
132
131
 
132
+ # Heuristic: choose a sliding-window batch size automatically when not provided.
133
+ def _choose_sw_batch_size(roi: tuple[int, int], device_obj: torch.device) -> int:
134
+ """Heuristic selection based on ROI area and device capabilities.
135
+
136
+ Baseline: VAL_SW_BATCH for 512x512 on a mid-range GPU. Scale batch size
137
+ inversely with ROI area and up-weight for devices with more memory
138
+ (e.g. macOS unified memory / MPS).
139
+ """
140
+ base_area = 512 * 512
141
+ roi_area = max(1, roi[0] * roi[1])
142
+ area_ratio = base_area / roi_area
143
+
144
+ # Try to estimate system RAM (in GB) on POSIX systems; conservative fallback.
145
+ total_ram_gb: float | None = None
146
+ try:
147
+ pages = os.sysconf("SC_PHYS_PAGES")
148
+ page_size = os.sysconf("SC_PAGE_SIZE")
149
+ total_ram_gb = (pages * page_size) / (1024**3)
150
+ except Exception:
151
+ total_ram_gb = None
152
+
153
+ # Device factor: raise batch size for GPUs, raise more for large unified RAM (MPS).
154
+ if device_obj.type == "cuda":
155
+ device_factor = 2.0
156
+ elif device_obj.type == "mps":
157
+ # macOS unified memory benefits from larger batches when system RAM is large.
158
+ if total_ram_gb is not None:
159
+ device_factor = max(1.5, min(8.0, total_ram_gb / 8.0))
160
+ else:
161
+ device_factor = 3.0
162
+ else:
163
+ # CPU: be conservative and scale with available RAM if known.
164
+ if total_ram_gb is not None:
165
+ device_factor = max(0.5, min(2.0, total_ram_gb / 32.0))
166
+ else:
167
+ device_factor = 0.5
168
+
169
+ batch = max(1, int(VAL_SW_BATCH * area_ratio * device_factor))
170
+ # Clamp to avoid enormous batches on pathological inputs.
171
+ return min(max(batch, 1), 1024)
172
+
173
+ local_sw_batch = (
174
+ sw_batch_size
175
+ if sw_batch_size is not None
176
+ else _choose_sw_batch_size(roi_size, device_obj)
177
+ )
178
+
133
179
  amp_device = "cuda" if device_obj.type == "cuda" else "cpu"
134
180
  with (
135
181
  torch.inference_mode(),
@@ -145,17 +191,69 @@ def run_inference(
145
191
  logits = sliding_window_inference(
146
192
  img_t,
147
193
  roi_size,
148
- sw_batch_size,
194
+ local_sw_batch,
149
195
  model,
150
196
  overlap=overlap,
151
197
  sw_device=device_obj,
152
198
  device=torch.device("cpu"),
153
199
  mode=blend_mode,
154
200
  )
201
+ # MONAI's sliding_window_inference may return a Tensor, a tuple/list
202
+ # (e.g. when the model returns multiple outputs), or a dict. Normalise
203
+ # to a single Tensor here so downstream code and the type-checker see
204
+ # a consistent type.
205
+ if not isinstance(logits, torch.Tensor):
206
+ # Tuple / list -> first element is expected to be the logits Tensor.
207
+ if isinstance(logits, (tuple, list)):
208
+ if len(logits) > 0 and isinstance(logits[0], torch.Tensor):
209
+ logits = logits[0]
210
+ else:
211
+ # Fallback: try to coerce the first element to a tensor.
212
+ logits = torch.as_tensor(logits[0])
213
+ elif isinstance(logits, dict):
214
+ # Take the first tensor-like value from the dict.
215
+ found = False
216
+ for v in logits.values():
217
+ if isinstance(v, torch.Tensor):
218
+ logits = v
219
+ found = True
220
+ break
221
+ if not found:
222
+ # Coerce the first value to a tensor as a last resort.
223
+ first_val = next(iter(logits.values()))
224
+ logits = torch.as_tensor(first_val)
225
+ else:
226
+ # If it's some other type, attempt a coercion to Tensor.
227
+ logits = torch.as_tensor(logits)
155
228
  del img_t
156
229
 
157
230
  # Remove padding
158
231
  if pad_h or pad_w:
232
+ # Ensure we have a Tensor before accessing shape (some MONAI
233
+ # wrappers may return a tuple/dict). Normalise common container
234
+ # types to a single Tensor.
235
+ if not isinstance(logits, torch.Tensor):
236
+ if (
237
+ isinstance(logits, (tuple, list))
238
+ and len(logits) > 0
239
+ and isinstance(logits[0], torch.Tensor)
240
+ ):
241
+ logits = logits[0]
242
+ elif isinstance(logits, dict):
243
+ # take first tensor-like value
244
+ found = False
245
+ for v in logits.values():
246
+ if isinstance(v, torch.Tensor):
247
+ logits = v
248
+ found = True
249
+ break
250
+ if not found:
251
+ # fall back to coercing first value
252
+ first_val = next(iter(logits.values()))
253
+ logits = torch.as_tensor(first_val)
254
+ else:
255
+ logits = torch.as_tensor(logits)
256
+
159
257
  _, _, ph, pw = logits.shape
160
258
  logits = logits[
161
259
  :, :, padding[2] : ph - padding[3], padding[0] : pw - padding[1]
@@ -164,12 +262,15 @@ def run_inference(
164
262
  # ------------------------------------------------------------------
165
263
  # Post-processing: sigmoid + threshold + background comparison
166
264
  # ------------------------------------------------------------------
265
+ # Ensure logits is a Tensor before applying sigmoid.
266
+ if not isinstance(logits, torch.Tensor):
267
+ logits = torch.as_tensor(logits)
167
268
  probs = torch.sigmoid(logits)
168
269
  del logits
169
270
 
170
271
  inner_prob = probs[0, 0] # channel 0 = epithelium / inner
171
272
  outer_prob = probs[0, 1] # channel 1 = stroma / outer
172
- bg_prob = probs[0, 2] # channel 2 = background
273
+ bg_prob = probs[0, 2] # channel 2 = background
173
274
  del probs
174
275
 
175
276
  inner_mask = (inner_prob > 0.5) & (inner_prob > bg_prob)
@@ -22,15 +22,40 @@ from __future__ import annotations
22
22
 
23
23
  import logging
24
24
 
25
+ import macenko_pca
25
26
  import numpy as np
26
- import pyvips as pv
27
27
  from monai.data import MetaTensor
28
28
  from monai.transforms import MapTransform, Resize
29
29
  from scipy.ndimage import label, uniform_filter
30
30
  from scipy.ndimage import sum as ndimage_sum
31
- from skimage.color import rgb2gray
32
31
 
33
- import macenko_pca
32
+
33
+ # Lightweight local replacement for skimage.color.rgb2gray to avoid an
34
+ # optional dependency and to keep typing simple. This implements the
35
+ # standard luminance weights and expects input in the [0, 1] float range
36
+ # (matching skimage behavior). It returns a 2D array of floats in [0, 1].
37
+ def rgb2gray(image: np.ndarray) -> np.ndarray:
38
+ if image.ndim == 2:
39
+ return image
40
+ # Expect HWC or CHW: prefer HWC (common in this module)
41
+ if image.ndim == 3 and image.shape[-1] == 3:
42
+ # Use common luminance weights
43
+ weights = np.array([0.2989, 0.5870, 0.1140], dtype=image.dtype)
44
+ return np.dot(image[..., :3], weights)
45
+ # Fallback: reduce any trailing channel dimension
46
+ if image.ndim == 3:
47
+ return np.mean(image, axis=-1)
48
+ # Unexpected shape: coerce to 2D via mean
49
+ return np.mean(image, axis=-1)
50
+
51
+
52
+ def _get_pyvips():
53
+ try:
54
+ import pyvips as pv
55
+ except Exception as e:
56
+ raise RuntimeError("pyvips is required for this operation") from e
57
+ return pv
58
+
34
59
 
35
60
  # ---------------------------------------------------------------------------
36
61
  # Resolution-scaling transforms
@@ -40,14 +65,16 @@ import macenko_pca
40
65
  class Rescaled(MapTransform):
41
66
  """Rescale images from *in_res* to *out_res* microns-per-pixel."""
42
67
 
43
- def __init__(self, keys, in_res=0.46, out_res=1.37):
68
+ def __init__(self, keys, in_res=0.46, out_res=2):
44
69
  super().__init__(keys)
45
70
  self.in_res = in_res
46
71
  self.out_res = out_res
47
72
 
48
- def __call__(self, data, in_res=None, resize_kwargs={}):
73
+ def __call__(self, data, in_res=None, resize_kwargs=None):
49
74
  if in_res is None:
50
75
  in_res = self.in_res
76
+ if resize_kwargs is None:
77
+ resize_kwargs = {}
51
78
  for key in self.keys:
52
79
  image = data[key]
53
80
  height, width = image.shape[1:]
@@ -55,9 +82,7 @@ class Rescaled(MapTransform):
55
82
  new_width = width * in_res // self.out_res
56
83
 
57
84
  interpolation = (
58
- "linear"
59
- if len(image.shape) == 3 and image.shape[0] == 3
60
- else "nearest"
85
+ "linear" if len(image.shape) == 3 and image.shape[0] == 3 else "nearest"
61
86
  )
62
87
 
63
88
  if "mode" not in resize_kwargs:
@@ -99,9 +124,8 @@ class LoadImageAtScaled(MapTransform):
99
124
  for key in self.key_iterator(d):
100
125
  filepath = d[key]
101
126
  try:
102
- image_pv = pv.Image.new_from_file(
103
- str(filepath), **self.reader_kwargs
104
- )
127
+ pv = _get_pyvips()
128
+ image_pv = pv.Image.new_from_file(str(filepath), **self.reader_kwargs)
105
129
  except Exception as e:
106
130
  raise RuntimeError(
107
131
  f"Failed to load image {filepath} with pyvips: {e}"
@@ -118,9 +142,7 @@ class LoadImageAtScaled(MapTransform):
118
142
  ):
119
143
  scale_factor = self.in_res / self.target_res
120
144
  if abs(scale_factor - 1.0) > 1e-6:
121
- image_pv = image_pv.resize(
122
- scale_factor, kernel=self.resize_kernel
123
- )
145
+ image_pv = image_pv.resize(scale_factor, kernel=self.resize_kernel)
124
146
  applied_scale_factor = scale_factor
125
147
 
126
148
  img_array = image_pv.numpy()
@@ -141,9 +163,7 @@ class LoadImageAtScaled(MapTransform):
141
163
  if self.target_res is not None:
142
164
  meta_info["target_resolution"] = self.target_res
143
165
  if applied_scale_factor != 1.0:
144
- meta_info[
145
- "applied_geometric_scale_factor"
146
- ] = applied_scale_factor
166
+ meta_info["applied_geometric_scale_factor"] = applied_scale_factor
147
167
 
148
168
  d[key] = MetaTensor(img_array, meta=meta_info)
149
169
  return d
@@ -172,9 +192,7 @@ class TissueMaskd(MapTransform):
172
192
  bg_mask = detect_background(image)
173
193
  tissue_mask = ~bg_mask
174
194
  meta_info = {"original_channel_dim": "no_channel"}
175
- data[f"{key}_tissue_mask"] = MetaTensor(
176
- tissue_mask, meta=meta_info
177
- )
195
+ data[f"{key}_tissue_mask"] = MetaTensor(tissue_mask, meta=meta_info)
178
196
  return data
179
197
 
180
198
 
@@ -209,14 +227,21 @@ class MarcenkoDeconvolutiond(MapTransform):
209
227
  tissue_mask = resizer(tissue_mask)
210
228
  tissue_mask = tissue_mask[0]
211
229
 
230
+ # If a tissue_mask was provided as a tensor/MetaTensor, coerce to numpy
231
+ if tissue_mask is not None:
232
+ tissue_mask = np.asarray(tissue_mask)
233
+
212
234
  if tissue_mask is None:
213
235
  bg_mask = detect_background(image)
214
236
  tissue_mask = np.ascontiguousarray(~bg_mask).astype(bool)
215
237
 
238
+ # Ensure tissue_mask is a NumPy boolean array for downstream processing
239
+ tissue_mask = np.asarray(tissue_mask).astype(bool)
240
+
216
241
  return macenko_pca.rgb_separate_stains_macenko_pca(
217
242
  np.ascontiguousarray(image),
218
243
  None,
219
- mask_out=~tissue_mask.astype(bool),
244
+ mask_out=~tissue_mask,
220
245
  )
221
246
 
222
247
  def deconv(self, image, tissue_mask=None):
@@ -225,16 +250,12 @@ class MarcenkoDeconvolutiond(MapTransform):
225
250
 
226
251
  w_est = self.get_w_est(image, tissue_mask=tissue_mask)
227
252
 
228
- image_her = macenko_pca.color_deconvolution(
229
- np.ascontiguousarray(image), w_est
230
- )
253
+ image_her = macenko_pca.color_deconvolution(np.ascontiguousarray(image), w_est)
231
254
 
232
255
  hematox_index = macenko_pca.find_stain_index(
233
256
  self.stain_color_map["hematoxylin"], w_est
234
257
  )
235
- eosin_index = macenko_pca.find_stain_index(
236
- self.stain_color_map["eosin"], w_est
237
- )
258
+ eosin_index = macenko_pca.find_stain_index(self.stain_color_map["eosin"], w_est)
238
259
 
239
260
  image_her = image_her[..., [hematox_index, eosin_index, 2]]
240
261
 
@@ -341,9 +362,7 @@ def detect_background(
341
362
  del diff, nz, cmax
342
363
 
343
364
  gray_q = (
344
- 0.299 * image_np[..., 0]
345
- + 0.587 * image_np[..., 1]
346
- + 0.114 * image_np[..., 2]
365
+ 0.299 * image_np[..., 0] + 0.587 * image_np[..., 1] + 0.114 * image_np[..., 2]
347
366
  ).astype(np.uint8) >> 4
348
367
  entropy_map = np.zeros(gray_q.shape, dtype=np.float32)
349
368
  for b in range(16):
@@ -409,25 +428,17 @@ def compute_background_intensity(
409
428
 
410
429
  if np.sum(bg_for_io) >= 100:
411
430
  io_val = float(
412
- np.clip(
413
- np.mean(image_np[bg_for_io], dtype=np.float64), 200, 255
414
- )
431
+ np.clip(np.mean(image_np[bg_for_io], dtype=np.float64), 200, 255)
415
432
  )
416
433
  elif np.sum(bg_mask) >= 100:
417
- io_val = float(
418
- np.clip(
419
- np.mean(image_np[bg_mask], dtype=np.float64), 200, 255
420
- )
421
- )
434
+ io_val = float(np.clip(np.mean(image_np[bg_mask], dtype=np.float64), 200, 255))
422
435
  else:
423
436
  io_val = 240.0
424
437
  del bg_for_io
425
438
  return io_val
426
439
 
427
440
 
428
- def estimate_stain_params(
429
- rgb: np.ndarray, bg_mask: np.ndarray | None = None
430
- ) -> dict:
441
+ def estimate_stain_params(rgb: np.ndarray, bg_mask: np.ndarray | None = None) -> dict:
431
442
  """Estimate Macenko colour-deconvolution parameters from an RGB image."""
432
443
  if bg_mask is None:
433
444
  bg_mask = detect_background(rgb)
@@ -612,25 +623,16 @@ class EHOd(MapTransform):
612
623
  est_image = image
613
624
  est_bg = bg_mask
614
625
  if image.size > 16777216: # > 4096^2 pixels
615
- logging.info(
616
- "Down-sampling image for stain-parameter estimation"
617
- )
626
+ logging.info("Down-sampling image for stain-parameter estimation")
618
627
  scale = 4
619
628
  h, w = image.shape[:2]
620
629
  small_h, small_w = h // scale, w // scale
621
- resizer = Resize(
622
- spatial_size=(small_h, small_w), mode="linear"
623
- )
630
+ resizer = Resize(spatial_size=(small_h, small_w), mode="linear")
624
631
  est_image = (
625
- resizer(image.transpose((2, 0, 1)))
626
- .numpy()
627
- .transpose((1, 2, 0))
632
+ resizer(image.transpose((2, 0, 1))).numpy().transpose((1, 2, 0))
628
633
  )
629
634
  if est_bg is not None:
630
- est_bg = (
631
- resizer(est_bg[None].astype(np.float32)).numpy()[0]
632
- > 0.5
633
- )
635
+ est_bg = resizer(est_bg[None].astype(np.float32)).numpy()[0] > 0.5
634
636
 
635
637
  params = estimate_stain_params(
636
638
  np.ascontiguousarray(est_image).astype(np.uint8),
@@ -1,21 +0,0 @@
1
- # SPDX-FileCopyrightText: 2024-present barrettMCW <mjbarrett@mcw.edu>
2
- #
3
- # SPDX-License-Identifier: MIT
4
- """
5
- Colour preprocessing for H&E whole-slide images.
6
-
7
- This module re-exports the lower-level helper functions from
8
- :mod:`shell.transforms` so that existing call-sites (e.g. the OMERO
9
- tile-by-tile pipeline in :mod:`shell.infer_omero_wsi`) continue to work
10
- without modification.
11
-
12
- For the full set of MONAI dictionary transforms see
13
- :mod:`shell.transforms`.
14
- """
15
-
16
- from shell.transforms import ( # noqa: F401
17
- apply_eho_chunked,
18
- compute_background_intensity,
19
- detect_background,
20
- estimate_stain_params,
21
- )
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes