Skip to content

Commit 87a8265

Browse files
committed
merge
2 parents 050e6a8 + b3b5642 commit 87a8265

File tree

14 files changed

+229
-87
lines changed

14 files changed

+229
-87
lines changed

.readthedocs.yml

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@ build:
1111
tools:
1212
python: "3.12"
1313
apt_packages:
14-
- openslide-tools
1514
- libopenjp2-7-dev
1615
- libopenjp2-tools
1716

@@ -22,5 +21,7 @@ sphinx:
2221
# Optionally set the version of Python and requirements required to build your docs
2322
python:
2423
install:
25-
- requirements: requirements/requirements.txt
24+
# Install PyTorch CPU version first
25+
- requirements: requirements/requirements_cpu.txt
2626
- requirements: docs/requirements.txt
27+
- requirements: requirements/requirements.txt

requirements/requirements.txt

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
# torch installation
2-
--extra-index-url https://download.pytorch.org/whl/cu126; sys_platform != "darwin"
31
aiohttp>=3.8.1
42
albumentations>=1.3.0
53
bokeh>=3.1.1, <3.6.0

requirements/requirements_cpu.txt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
--index-url https://download.pytorch.org/whl/cpu
2+
torch
3+
torchvision

tests/conftest.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@
44

55
import os
66
import shutil
7+
import socket
78
import time
9+
from contextlib import closing
810
from pathlib import Path
911
from typing import TYPE_CHECKING
1012

@@ -16,6 +18,13 @@
1618
from tiatoolbox.data import _fetch_remote_sample
1719
from tiatoolbox.utils.env_detection import has_gpu, running_on_ci
1820

21+
# Reserve a free port for tileserver tests
22+
_TILES_ENV = "TIATOOLBOX_TILESERVER_PORT"
23+
if _TILES_ENV not in os.environ:
24+
with closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as sock:
25+
sock.bind(("127.0.0.1", 0))
26+
os.environ[_TILES_ENV] = str(sock.getsockname()[1])
27+
1928
if TYPE_CHECKING:
2029
from collections.abc import Callable
2130

tests/engines/test_nucleus_detection_engine.py

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@
1414
from tiatoolbox.annotation.storage import SQLiteStore
1515
from tiatoolbox.models.engine.nucleus_detector import (
1616
NucleusDetector,
17-
# _flatten_predictions_to_dask,
1817
)
1918
from tiatoolbox.utils import env_detection as toolbox_env
2019
from tiatoolbox.utils.misc import imwrite
@@ -85,12 +84,12 @@ def test_write_detection_records_to_store_no_class_dict() -> None:
8584
assert len(dummy_store.values()) == 1
8685
assert total == 1
8786
annotation = next(iter(dummy_store.values()))
88-
assert annotation.properties["class"] == 0
87+
assert annotation.properties["type"] == 0
8988
dummy_store.close()
9089

9190

9291
def test_nucleus_detector_patch_annotation_store_output(
93-
remote_sample: Callable, tmp_path: Path
92+
remote_sample: Callable, track_tmp_path: Path
9493
) -> None:
9594
"""Test for nucleus detection engine in patch mode."""
9695
mini_wsi_svs = Path(remote_sample("wsi1_2k_2k_svs"))
@@ -106,7 +105,7 @@ def test_nucleus_detector_patch_annotation_store_output(
106105

107106
pretrained_model = "sccnn-conic"
108107

109-
save_dir = tmp_path
108+
save_dir = track_tmp_path
110109

111110
nucleus_detector = NucleusDetector(model=pretrained_model)
112111
_ = nucleus_detector.run(
@@ -195,7 +194,7 @@ def test_nucleus_detector_patches_dict_output(
195194

196195

197196
def test_nucleus_detector_patches_zarr_output(
198-
remote_sample: Callable, tmp_path: Path
197+
remote_sample: Callable, track_tmp_path: Path
199198
) -> None:
200199
"""Test for nucleus detection engine in patch mode."""
201200
mini_wsi_svs = Path(remote_sample("wsi1_2k_2k_svs"))
@@ -212,7 +211,7 @@ def test_nucleus_detector_patches_zarr_output(
212211

213212
nucleus_detector = NucleusDetector(model=pretrained_model)
214213

215-
save_dir = tmp_path
214+
save_dir = track_tmp_path
216215

217216
output_path = nucleus_detector.run(
218217
patch_mode=True,
@@ -240,13 +239,13 @@ def test_nucleus_detector_patches_zarr_output(
240239
_rm_dir(save_dir)
241240

242241

243-
def test_nucleus_detector_wsi(remote_sample: Callable, tmp_path: Path) -> None:
242+
def test_nucleus_detector_wsi(remote_sample: Callable, track_tmp_path: Path) -> None:
244243
"""Test for nucleus detection engine."""
245244
mini_wsi_svs = Path(remote_sample("wsi4_512_512_svs"))
246245

247246
pretrained_model = "sccnn-conic"
248247

249-
save_dir = tmp_path
248+
save_dir = track_tmp_path
250249

251250
nucleus_detector = NucleusDetector(model=pretrained_model)
252251
nucleus_detector.drop_keys = []
@@ -267,7 +266,7 @@ def test_nucleus_detector_wsi(remote_sample: Callable, tmp_path: Path) -> None:
267266
store = SQLiteStore.open(save_dir / "wsi4_512_512.db")
268267
assert 255 <= len(store.values()) <= 265
269268
annotation = next(iter(store.values()))
270-
assert annotation.properties["class"] == "test_nucleus"
269+
assert annotation.properties["type"] == "test_nucleus"
271270
store.close()
272271

273272
nucleus_detector.drop_keys = ["probabilities"]

tests/test_app_bokeh.py

Lines changed: 72 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import re
1010
import time
1111
from pathlib import Path
12+
from types import SimpleNamespace
1213
from typing import TYPE_CHECKING
1314

1415
import bokeh.models as bkmodels
@@ -25,7 +26,7 @@
2526
from scipy.ndimage import label
2627

2728
from tiatoolbox.data import _fetch_remote_sample
28-
from tiatoolbox.visualization.bokeh_app import main
29+
from tiatoolbox.visualization.bokeh_app import app_hooks, main
2930
from tiatoolbox.visualization.tileserver import TileServer
3031
from tiatoolbox.visualization.ui_utils import get_level_by_extent
3132

@@ -41,6 +42,13 @@
4142
GRIDLINES = 2
4243

4344

45+
class _DummySessionContext:
46+
"""Simple shim matching the subset of Bokeh's SessionContext we use."""
47+
48+
def __init__(self: _DummySessionContext, user: str) -> None:
49+
self.request = SimpleNamespace(arguments={"user": user})
50+
51+
4452
# helper functions and fixtures
4553
def get_tile(layer: str, x: float, y: float, z: float, *, show: bool) -> np.ndarray:
4654
"""Get a tile from the server.
@@ -77,7 +85,9 @@ def get_renderer_prop(prop: str) -> json:
7785
The property to get.
7886
7987
"""
80-
resp = main.UI["s"].get(f"http://{main.host2}:5000/tileserver/renderer/{prop}")
88+
resp = main.UI["s"].get(
89+
f"http://{main.host2}:{main.port}/tileserver/renderer/{prop}",
90+
)
8191
return resp.json()
8292

8393

@@ -144,7 +154,7 @@ def run_app() -> None:
144154
layers={},
145155
)
146156
CORS(app, send_wildcard=True)
147-
app.run(host="127.0.0.1", threaded=True)
157+
app.run(host="127.0.0.1", port=int(main.port), threaded=True)
148158

149159

150160
@pytest.fixture(scope="module")
@@ -376,13 +386,17 @@ def test_type_cmap_select(doc: Document) -> None:
376386

377387
# remove the type cmap
378388
cmap_select.value = []
379-
resp = main.UI["s"].get(f"http://{main.host2}:5000/tileserver/secondary_cmap")
389+
resp = main.UI["s"].get(
390+
f"http://{main.host2}:{main.port}/tileserver/secondary_cmap"
391+
)
380392
assert resp.json()["score_prop"] == "None"
381393

382394
# check callback works regardless of order
383395
cmap_select.value = ["0"]
384396
cmap_select.value = ["0", "prob"]
385-
resp = main.UI["s"].get(f"http://{main.host2}:5000/tileserver/secondary_cmap")
397+
resp = main.UI["s"].get(
398+
f"http://{main.host2}:{main.port}/tileserver/secondary_cmap"
399+
)
386400
assert resp.json()["score_prop"] == "prob"
387401

388402

@@ -706,24 +720,24 @@ def test_cmap_select(doc: Document) -> None:
706720
main.UI["cprop_input"].value = ["prob"]
707721
# set to jet
708722
cmap_select.value = "jet"
709-
resp = main.UI["s"].get(f"http://{main.host2}:5000/tileserver/cmap")
723+
resp = main.UI["s"].get(f"http://{main.host2}:{main.port}/tileserver/cmap")
710724
assert resp.json() == "jet"
711725
# set to dict
712726
cmap_select.value = "dict"
713-
resp = main.UI["s"].get(f"http://{main.host2}:5000/tileserver/cmap")
727+
resp = main.UI["s"].get(f"http://{main.host2}:{main.port}/tileserver/cmap")
714728
assert isinstance(resp.json(), dict)
715729

716730
main.UI["cprop_input"].value = ["type"]
717731
# should now be the type mapping
718-
resp = main.UI["s"].get(f"http://{main.host2}:5000/tileserver/cmap")
732+
resp = main.UI["s"].get(f"http://{main.host2}:{main.port}/tileserver/cmap")
719733
for key in main.UI["vstate"].mapper:
720734
assert str(key) in resp.json()
721735
assert np.all(
722736
np.array(resp.json()[str(key)]) == np.array(main.UI["vstate"].mapper[key]),
723737
)
724738
# set the cmap to "coolwarm"
725739
cmap_select.value = "coolwarm"
726-
resp = main.UI["s"].get(f"http://{main.host2}:5000/tileserver/cmap")
740+
resp = main.UI["s"].get(f"http://{main.host2}:{main.port}/tileserver/cmap")
727741
# as cprop is type (categorical), it should have had no effect
728742
for key in main.UI["vstate"].mapper:
729743
assert str(key) in resp.json()
@@ -732,7 +746,7 @@ def test_cmap_select(doc: Document) -> None:
732746
)
733747

734748
main.UI["cprop_input"].value = ["prob"]
735-
resp = main.UI["s"].get(f"http://{main.host2}:5000/tileserver/cmap")
749+
resp = main.UI["s"].get(f"http://{main.host2}:{main.port}/tileserver/cmap")
736750
# should be coolwarm as that is the last cmap we set, and prob is continuous
737751
assert resp.json() == "coolwarm"
738752

@@ -778,3 +792,51 @@ def test_clearing_doc(doc: Document) -> None:
778792
"""Test that the doc can be cleared."""
779793
doc.clear()
780794
assert len(doc.roots) == 0
795+
796+
797+
def test_app_hooks_session_destroyed(monkeypatch: pytest.MonkeyPatch) -> None:
798+
"""Hook should call reset endpoint and exit."""
799+
recorded: dict[str, object] = {}
800+
801+
def fake_get(url: str, *, timeout: int) -> None:
802+
"""Fake requests.get to record parameters."""
803+
recorded["url"] = url
804+
recorded["timeout"] = timeout
805+
806+
monkeypatch.setattr(app_hooks, "PORT", "6150")
807+
monkeypatch.setattr(app_hooks.requests, "get", fake_get)
808+
exited = False
809+
810+
def fake_exit() -> None:
811+
"""Fake sys.exit to record call."""
812+
nonlocal exited
813+
exited = True
814+
815+
monkeypatch.setattr(app_hooks, "sys", SimpleNamespace(exit=fake_exit))
816+
app_hooks.on_session_destroyed(_DummySessionContext("user-1"))
817+
assert recorded["url"] == "http://127.0.0.1:6150/tileserver/reset/user-1"
818+
assert recorded["timeout"] == 5
819+
assert exited
820+
821+
822+
def test_app_hooks_session_destroyed_suppresses_timeout(
823+
monkeypatch: pytest.MonkeyPatch,
824+
) -> None:
825+
"""ReadTimeout should be suppressed and exit still called."""
826+
827+
def fake_get(*_: object, **__: object) -> None:
828+
"""Fake requests.get to raise ReadTimeout."""
829+
raise app_hooks.requests.exceptions.ReadTimeout # type: ignore[attr-defined]
830+
831+
monkeypatch.setattr(app_hooks, "PORT", "6160")
832+
monkeypatch.setattr(app_hooks.requests, "get", fake_get)
833+
exited = False
834+
835+
def fake_exit() -> None:
836+
"""Fake sys.exit to record call."""
837+
nonlocal exited
838+
exited = True
839+
840+
monkeypatch.setattr(app_hooks, "sys", SimpleNamespace(exit=fake_exit))
841+
app_hooks.on_session_destroyed(_DummySessionContext("user-2"))
842+
assert exited

tests/test_json_config_bokeh.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from __future__ import annotations
44

5+
import os
56
import time
67
from contextlib import suppress
78
from threading import Thread
@@ -14,6 +15,8 @@
1415
from tiatoolbox.cli.visualize import run_bokeh, run_tileserver
1516
from tiatoolbox.data import _fetch_remote_sample
1617

18+
PORT = os.environ.get("TIATOOLBOX_TILESERVER_PORT", "5000")
19+
1720
if TYPE_CHECKING:
1821
from pathlib import Path
1922

@@ -65,7 +68,10 @@ def bk_session(data_path: dict[str, Path]) -> ClientSession:
6568
yield session
6669
session.close()
6770
with suppress(requests.exceptions.ConnectionError):
68-
requests.post("http://localhost:5000/tileserver/shutdown", timeout=2)
71+
requests.post(
72+
f"http://localhost:{PORT}/tileserver/shutdown",
73+
timeout=2,
74+
)
6975

7076

7177
def test_slides_available(bk_session: ClientSession) -> None:

tests/test_server_bokeh.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from __future__ import annotations
44

5+
import os
56
import time
67
from contextlib import suppress
78
from threading import Thread
@@ -16,6 +17,8 @@
1617
from tiatoolbox.cli.visualize import run_bokeh, run_tileserver
1718
from tiatoolbox.data import _fetch_remote_sample
1819

20+
PORT = os.environ.get("TIATOOLBOX_TILESERVER_PORT", "5000")
21+
1922
if TYPE_CHECKING:
2023
from pathlib import Path
2124

@@ -72,7 +75,10 @@ def bk_session(data_path: dict[str, Path]) -> ClientSession:
7275
yield session
7376
session.close()
7477
with suppress(requests.exceptions.ConnectionError):
75-
requests.post("http://localhost:5000/tileserver/shutdown", timeout=2)
78+
requests.post(
79+
f"http://localhost:{PORT}/tileserver/shutdown",
80+
timeout=2,
81+
)
7682

7783

7884
def test_slides_available(bk_session: ClientSession) -> None:

tiatoolbox/annotation/storage.py

Lines changed: 28 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2861,6 +2861,33 @@ def _initialize_query_string_parameters(
28612861

28622862
return query_string, query_parameters
28632863

2864+
@staticmethod
2865+
def _warn_if_query_not_using_index(
2866+
cur: sqlite3.Cursor,
2867+
query_string: str,
2868+
query_parameters: dict[str, object],
2869+
) -> None:
2870+
"""Log a warning when SQLite does not use an index."""
2871+
query_plan = cur.execute(
2872+
"EXPLAIN QUERY PLAN " + query_string,
2873+
query_parameters,
2874+
).fetchall()
2875+
uses_index = False
2876+
for plan in query_plan:
2877+
detail = str(plan[-1]).upper()
2878+
# macOS SQLite may report "USING COVERING INDEX".
2879+
if "AUTOMATIC" not in detail and (
2880+
"USING INDEX" in detail or "USING COVERING INDEX" in detail
2881+
):
2882+
uses_index = True
2883+
break
2884+
if not uses_index:
2885+
logger.warning(
2886+
"Query is not using an index. "
2887+
"Consider adding an index to improve performance.",
2888+
stacklevel=2,
2889+
)
2890+
28642891
def _query(
28652892
self: SQLiteStore,
28662893
columns: str,
@@ -2970,16 +2997,7 @@ def _query(
29702997

29712998
# Warn if the query is not using an index
29722999
if index_warning:
2973-
query_plan = cur.execute(
2974-
"EXPLAIN QUERY PLAN " + query_string,
2975-
query_parameters,
2976-
).fetchone()
2977-
if "USING INDEX" not in query_plan[-1]:
2978-
logger.warning(
2979-
"Query is not using an index. "
2980-
"Consider adding an index to improve performance.",
2981-
stacklevel=2,
2982-
)
3000+
self._warn_if_query_not_using_index(cur, query_string, query_parameters)
29833001
# if area column exists, sort annotations by area
29843002
if "area" in self.table_columns:
29853003
query_string += "\nORDER BY area DESC"

0 commit comments

Comments
 (0)