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.
- {lavlab_shell-0.3.0 → lavlab_shell-0.3.2}/PKG-INFO +1 -1
- {lavlab_shell-0.3.0 → lavlab_shell-0.3.2}/src/shell/__about__.py +1 -1
- {lavlab_shell-0.3.0 → lavlab_shell-0.3.2}/src/shell/cli.py +73 -24
- {lavlab_shell-0.3.0 → lavlab_shell-0.3.2}/src/shell/infer_omero_wsi.py +299 -65
- {lavlab_shell-0.3.0 → lavlab_shell-0.3.2}/src/shell/infer_wsi.py +21 -8
- {lavlab_shell-0.3.0 → lavlab_shell-0.3.2}/src/shell/inference.py +109 -8
- {lavlab_shell-0.3.0 → lavlab_shell-0.3.2}/src/shell/transforms.py +56 -54
- lavlab_shell-0.3.0/src/shell/preprocessing.py +0 -21
- {lavlab_shell-0.3.0 → lavlab_shell-0.3.2}/.devcontainer/Dockerfile +0 -0
- {lavlab_shell-0.3.0 → lavlab_shell-0.3.2}/.editorconfig +0 -0
- {lavlab_shell-0.3.0 → lavlab_shell-0.3.2}/.github/dependabot.yml +0 -0
- {lavlab_shell-0.3.0 → lavlab_shell-0.3.2}/.github/workflows/build.yml +0 -0
- {lavlab_shell-0.3.0 → lavlab_shell-0.3.2}/.github/workflows/lint.yml +0 -0
- {lavlab_shell-0.3.0 → lavlab_shell-0.3.2}/.github/workflows/publish.yml +0 -0
- {lavlab_shell-0.3.0 → lavlab_shell-0.3.2}/.github/workflows/pytest.yml +0 -0
- {lavlab_shell-0.3.0 → lavlab_shell-0.3.2}/.gitignore +0 -0
- {lavlab_shell-0.3.0 → lavlab_shell-0.3.2}/.pre-commit-config.yaml +0 -0
- {lavlab_shell-0.3.0 → lavlab_shell-0.3.2}/CONTRIBUTING.md +0 -0
- {lavlab_shell-0.3.0 → lavlab_shell-0.3.2}/Dockerfile +0 -0
- {lavlab_shell-0.3.0 → lavlab_shell-0.3.2}/LICENSE.txt +0 -0
- {lavlab_shell-0.3.0 → lavlab_shell-0.3.2}/Makefile +0 -0
- {lavlab_shell-0.3.0 → lavlab_shell-0.3.2}/README.md +0 -0
- {lavlab_shell-0.3.0 → lavlab_shell-0.3.2}/docs/api.md +0 -0
- {lavlab_shell-0.3.0 → lavlab_shell-0.3.2}/docs/index.md +0 -0
- {lavlab_shell-0.3.0 → lavlab_shell-0.3.2}/mkdocs.yml +0 -0
- {lavlab_shell-0.3.0 → lavlab_shell-0.3.2}/pyproject.toml +0 -0
- {lavlab_shell-0.3.0 → lavlab_shell-0.3.2}/requirements/requirements-docs.txt +0 -0
- {lavlab_shell-0.3.0 → lavlab_shell-0.3.2}/requirements/requirements-lint.txt +0 -0
- {lavlab_shell-0.3.0 → lavlab_shell-0.3.2}/requirements/requirements-test.txt +0 -0
- {lavlab_shell-0.3.0 → lavlab_shell-0.3.2}/requirements/requirements-types.txt +0 -0
- {lavlab_shell-0.3.0 → lavlab_shell-0.3.2}/requirements.txt +0 -0
- {lavlab_shell-0.3.0 → lavlab_shell-0.3.2}/src/shell/__init__.py +0 -0
- {lavlab_shell-0.3.0 → lavlab_shell-0.3.2}/src/shell/benchmark.py +0 -0
- {lavlab_shell-0.3.0 → lavlab_shell-0.3.2}/src/shell/model.py +0 -0
- {lavlab_shell-0.3.0 → lavlab_shell-0.3.2}/src/shell/py.typed +0 -0
- {lavlab_shell-0.3.0 → lavlab_shell-0.3.2}/src/shell/weights/model_v1.pth +0 -0
- {lavlab_shell-0.3.0 → lavlab_shell-0.3.2}/tests/__init__.py +0 -0
- {lavlab_shell-0.3.0 → lavlab_shell-0.3.2}/tests/conftest.py +0 -0
- {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.
|
|
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
|
|
@@ -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
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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 —
|
|
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.
|
|
1050
|
+
from shell.transforms import apply_eho_chunked
|
|
888
1051
|
|
|
889
|
-
|
|
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
|
-
|
|
961
|
-
|
|
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
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
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
|
-
"
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
1573
|
-
#
|
|
1574
|
-
|
|
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
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
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
|
-
|
|
1588
|
-
|
|
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
|
|
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
|
-
|
|
257
|
-
|
|
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
|
-
|
|
267
|
-
|
|
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] = (
|
|
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 =
|
|
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
|
-
|
|
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]
|
|
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
|
-
|
|
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=
|
|
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
|
-
|
|
103
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|