wsi-toolbox 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- wsi_toolbox/__init__.py +122 -0
- wsi_toolbox/app.py +874 -0
- wsi_toolbox/cli.py +599 -0
- wsi_toolbox/commands/__init__.py +66 -0
- wsi_toolbox/commands/clustering.py +198 -0
- wsi_toolbox/commands/data_loader.py +219 -0
- wsi_toolbox/commands/dzi.py +160 -0
- wsi_toolbox/commands/patch_embedding.py +196 -0
- wsi_toolbox/commands/pca.py +206 -0
- wsi_toolbox/commands/preview.py +394 -0
- wsi_toolbox/commands/show.py +171 -0
- wsi_toolbox/commands/umap_embedding.py +174 -0
- wsi_toolbox/commands/wsi.py +223 -0
- wsi_toolbox/common.py +148 -0
- wsi_toolbox/models.py +30 -0
- wsi_toolbox/utils/__init__.py +109 -0
- wsi_toolbox/utils/analysis.py +174 -0
- wsi_toolbox/utils/hdf5_paths.py +232 -0
- wsi_toolbox/utils/plot.py +227 -0
- wsi_toolbox/utils/progress.py +207 -0
- wsi_toolbox/utils/seed.py +26 -0
- wsi_toolbox/utils/st.py +55 -0
- wsi_toolbox/utils/white.py +121 -0
- wsi_toolbox/watcher.py +256 -0
- wsi_toolbox/wsi_files.py +619 -0
- wsi_toolbox-0.2.0.dist-info/METADATA +253 -0
- wsi_toolbox-0.2.0.dist-info/RECORD +30 -0
- wsi_toolbox-0.2.0.dist-info/WHEEL +4 -0
- wsi_toolbox-0.2.0.dist-info/entry_points.txt +3 -0
- wsi_toolbox-0.2.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
from typing import Iterable, Optional, TypeVar
|
|
2
|
+
|
|
3
|
+
from tqdm import tqdm
|
|
4
|
+
|
|
5
|
+
T = TypeVar("T")
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class BaseProgress:
|
|
9
|
+
"""Base interface for progress bars"""
|
|
10
|
+
|
|
11
|
+
def update(self, n: int = 1) -> None:
|
|
12
|
+
raise NotImplementedError
|
|
13
|
+
|
|
14
|
+
def set_description(self, desc: str = None, refresh: bool = True) -> None:
|
|
15
|
+
raise NotImplementedError
|
|
16
|
+
|
|
17
|
+
def set_postfix(self, ordered_dict=None, **kwargs) -> None:
|
|
18
|
+
raise NotImplementedError
|
|
19
|
+
|
|
20
|
+
def refresh(self) -> None:
|
|
21
|
+
"""Force refresh the progress bar display"""
|
|
22
|
+
pass
|
|
23
|
+
|
|
24
|
+
def close(self) -> None:
|
|
25
|
+
raise NotImplementedError
|
|
26
|
+
|
|
27
|
+
def __enter__(self):
|
|
28
|
+
return self
|
|
29
|
+
|
|
30
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
31
|
+
self.close()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class TqdmProgress(BaseProgress):
|
|
35
|
+
"""tqdm wrapper"""
|
|
36
|
+
|
|
37
|
+
def __init__(self, iterable: Optional[Iterable[T]] = None, total: Optional[int] = None, desc: str = "", **kwargs):
|
|
38
|
+
self._pbar = tqdm(iterable=iterable, total=total, desc=desc, **kwargs)
|
|
39
|
+
|
|
40
|
+
def update(self, n: int = 1) -> None:
|
|
41
|
+
self._pbar.update(n)
|
|
42
|
+
|
|
43
|
+
def set_description(self, desc: str = None, refresh: bool = True) -> None:
|
|
44
|
+
self._pbar.set_description(desc, refresh=refresh)
|
|
45
|
+
|
|
46
|
+
def set_postfix(self, ordered_dict=None, **kwargs) -> None:
|
|
47
|
+
self._pbar.set_postfix(ordered_dict, **kwargs)
|
|
48
|
+
|
|
49
|
+
def refresh(self) -> None:
|
|
50
|
+
self._pbar.refresh()
|
|
51
|
+
|
|
52
|
+
def close(self) -> None:
|
|
53
|
+
self._pbar.close()
|
|
54
|
+
|
|
55
|
+
def __iter__(self):
|
|
56
|
+
return iter(self._pbar)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class StreamlitProgress(BaseProgress):
|
|
60
|
+
"""Streamlit progress bar wrapper"""
|
|
61
|
+
|
|
62
|
+
def __init__(self, iterable: Optional[Iterable[T]] = None, total: Optional[int] = None, desc: str = "", **kwargs):
|
|
63
|
+
# import streamlit as st はここに置くこと
|
|
64
|
+
# import streamlit as st はここに置くこと
|
|
65
|
+
# import streamlit as st はここに置くこと
|
|
66
|
+
import streamlit as st # noqa: E402
|
|
67
|
+
|
|
68
|
+
self.iterable = iterable
|
|
69
|
+
self.total = (
|
|
70
|
+
total
|
|
71
|
+
if total is not None
|
|
72
|
+
else (len(iterable) if iterable is not None and hasattr(iterable, "__len__") else None)
|
|
73
|
+
)
|
|
74
|
+
self.desc = desc
|
|
75
|
+
self.n = 0
|
|
76
|
+
self.kwargs = kwargs
|
|
77
|
+
|
|
78
|
+
# 説明テキスト用のコンテナ
|
|
79
|
+
self.text_container = st.empty()
|
|
80
|
+
if desc:
|
|
81
|
+
self.text_container.text(desc)
|
|
82
|
+
# プログレスバー
|
|
83
|
+
self.progress_bar = st.progress(0)
|
|
84
|
+
# 後置テキスト用のコンテナ
|
|
85
|
+
self.postfix_container = st.empty()
|
|
86
|
+
|
|
87
|
+
def update(self, n: int = 1) -> None:
|
|
88
|
+
"""進捗を更新する"""
|
|
89
|
+
self.n += n
|
|
90
|
+
if self.total:
|
|
91
|
+
self.progress_bar.progress(min(self.n / self.total, 1.0))
|
|
92
|
+
|
|
93
|
+
def set_description(self, desc: str = None, refresh: bool = True) -> None:
|
|
94
|
+
"""説明テキストを更新する"""
|
|
95
|
+
if desc is not None:
|
|
96
|
+
self.desc = desc
|
|
97
|
+
# self.text_container.text(desc)
|
|
98
|
+
self.text_container.markdown(
|
|
99
|
+
'<p style="font-size:14px; color:gray;">' + desc + "</p>", unsafe_allow_html=True
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
def set_postfix(self, ordered_dict=None, **kwargs) -> None:
|
|
103
|
+
"""後置テキストを設定する"""
|
|
104
|
+
# ordered_dictとkwargsを組み合わせる
|
|
105
|
+
postfix_dict = {}
|
|
106
|
+
if ordered_dict:
|
|
107
|
+
postfix_dict.update(ordered_dict)
|
|
108
|
+
if kwargs:
|
|
109
|
+
postfix_dict.update(kwargs)
|
|
110
|
+
|
|
111
|
+
if postfix_dict:
|
|
112
|
+
# 辞書を文字列に変換して表示
|
|
113
|
+
postfix_str = ", ".join(f"{k}={v}" for k, v in postfix_dict.items())
|
|
114
|
+
self.postfix_container.text(f"状態: {postfix_str}")
|
|
115
|
+
|
|
116
|
+
def close(self) -> None:
|
|
117
|
+
"""プログレスバーを完了状態にする"""
|
|
118
|
+
if self.total:
|
|
119
|
+
self.progress_bar.progress(1.0)
|
|
120
|
+
self.text_container.empty()
|
|
121
|
+
|
|
122
|
+
def refresh(self):
|
|
123
|
+
"""不要なので何もしない"""
|
|
124
|
+
pass
|
|
125
|
+
|
|
126
|
+
def __iter__(self):
|
|
127
|
+
"""イテレータとして使用できるようにする"""
|
|
128
|
+
if self.iterable is None:
|
|
129
|
+
raise ValueError("このプログレスバーはイテレータとして使用できません")
|
|
130
|
+
|
|
131
|
+
for obj in self.iterable:
|
|
132
|
+
yield obj
|
|
133
|
+
self.update(1)
|
|
134
|
+
|
|
135
|
+
self.close()
|
|
136
|
+
|
|
137
|
+
def __enter__(self):
|
|
138
|
+
"""コンテキストマネージャとして使用できるようにする"""
|
|
139
|
+
return self
|
|
140
|
+
|
|
141
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
142
|
+
"""コンテキスト終了時に呼ばれる"""
|
|
143
|
+
self.close()
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
class DummyProgress(BaseProgress):
|
|
147
|
+
"""Dummy progress bar (no output)"""
|
|
148
|
+
|
|
149
|
+
def __init__(self, iterable: Optional[Iterable[T]] = None, total: Optional[int] = None, desc: str = "", **kwargs):
|
|
150
|
+
self.iterable = iterable
|
|
151
|
+
self.total = total
|
|
152
|
+
self.desc = desc
|
|
153
|
+
self.n = 0
|
|
154
|
+
|
|
155
|
+
def update(self, n: int = 1) -> None:
|
|
156
|
+
self.n += n
|
|
157
|
+
|
|
158
|
+
def set_description(self, desc: str = None, refresh: bool = True) -> None:
|
|
159
|
+
if desc is not None:
|
|
160
|
+
self.desc = desc
|
|
161
|
+
|
|
162
|
+
def set_postfix(self, ordered_dict=None, **kwargs) -> None:
|
|
163
|
+
pass
|
|
164
|
+
|
|
165
|
+
def close(self) -> None:
|
|
166
|
+
pass
|
|
167
|
+
|
|
168
|
+
def __iter__(self):
|
|
169
|
+
if self.iterable is None:
|
|
170
|
+
raise ValueError("No iterable provided")
|
|
171
|
+
for obj in self.iterable:
|
|
172
|
+
yield obj
|
|
173
|
+
self.update(1)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def Progress(
|
|
177
|
+
iterable: Optional[Iterable[T]] = None,
|
|
178
|
+
backend: str = "tqdm",
|
|
179
|
+
total: Optional[int] = None,
|
|
180
|
+
desc: str = "",
|
|
181
|
+
**kwargs,
|
|
182
|
+
) -> BaseProgress:
|
|
183
|
+
"""
|
|
184
|
+
Create a progress bar with the specified backend
|
|
185
|
+
|
|
186
|
+
Args:
|
|
187
|
+
iterable: Optional iterable to track
|
|
188
|
+
backend: Backend type ("tqdm", "streamlit", "dummy")
|
|
189
|
+
total: Total iterations (required if iterable is None)
|
|
190
|
+
desc: Description text
|
|
191
|
+
**kwargs: Additional arguments passed to the backend
|
|
192
|
+
|
|
193
|
+
Returns:
|
|
194
|
+
BaseProgress instance
|
|
195
|
+
"""
|
|
196
|
+
if backend == "tqdm":
|
|
197
|
+
return TqdmProgress(iterable=iterable, total=total, desc=desc, **kwargs)
|
|
198
|
+
elif backend == "streamlit":
|
|
199
|
+
try:
|
|
200
|
+
return StreamlitProgress(iterable=iterable, total=total, desc=desc, **kwargs)
|
|
201
|
+
except ImportError:
|
|
202
|
+
print("streamlit not found, falling back to dummy progress")
|
|
203
|
+
return DummyProgress(iterable=iterable, total=total, desc=desc, **kwargs)
|
|
204
|
+
elif backend == "dummy":
|
|
205
|
+
return DummyProgress(iterable=iterable, total=total, desc=desc, **kwargs)
|
|
206
|
+
else:
|
|
207
|
+
raise ValueError(f"Unknown backend: {backend}")
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import random
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
|
|
5
|
+
__GLOBAL_SEED = 42
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def get_global_seed():
|
|
9
|
+
return __GLOBAL_SEED
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def fix_global_seed(seed=None):
|
|
13
|
+
# Lazy import: torch is slow to load (~800ms), defer until needed
|
|
14
|
+
import torch # noqa: PLC0415
|
|
15
|
+
|
|
16
|
+
if seed is None:
|
|
17
|
+
seed = get_global_seed()
|
|
18
|
+
global __GLOBAL_SEED
|
|
19
|
+
random.seed(seed)
|
|
20
|
+
np.random.seed(seed)
|
|
21
|
+
torch.manual_seed(seed)
|
|
22
|
+
torch.random.manual_seed(seed)
|
|
23
|
+
torch.cuda.manual_seed(seed)
|
|
24
|
+
torch.backends.cudnn.deterministic = True
|
|
25
|
+
torch.use_deterministic_algorithms = True
|
|
26
|
+
__GLOBAL_SEED = seed
|
wsi_toolbox/utils/st.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
from contextlib import contextmanager
|
|
2
|
+
|
|
3
|
+
import streamlit as st
|
|
4
|
+
|
|
5
|
+
HORIZONTAL_STYLE = """
|
|
6
|
+
<style class="hide-element">
|
|
7
|
+
/* Hides the style container and removes the extra spacing */
|
|
8
|
+
.element-container:has(.hide-element) {
|
|
9
|
+
display: none;
|
|
10
|
+
}
|
|
11
|
+
/*
|
|
12
|
+
The selector for >.element-container is necessary to avoid selecting the whole
|
|
13
|
+
body of the streamlit app, which is also a stVerticalBlock.
|
|
14
|
+
*/
|
|
15
|
+
div[data-testid="stVerticalBlock"]:has(> .element-container .horizontal-marker) {
|
|
16
|
+
display: flex;
|
|
17
|
+
flex-direction: row !important;
|
|
18
|
+
flex-wrap: wrap;
|
|
19
|
+
gap: 0.5rem;
|
|
20
|
+
align-items: baseline;
|
|
21
|
+
}
|
|
22
|
+
/* Buttons and their parent container all have a width of 704px, which we need to override */
|
|
23
|
+
div[data-testid="stVerticalBlock"]:has(> .element-container .horizontal-marker) div {
|
|
24
|
+
width: max-content !important;
|
|
25
|
+
}
|
|
26
|
+
/* Selectbox container */
|
|
27
|
+
div[data-testid="stVerticalBlock"]:has(> .element-container .horizontal-marker) div[data-testid="stSelectbox"] {
|
|
28
|
+
display: flex !important;
|
|
29
|
+
flex-direction: row !important;
|
|
30
|
+
align-items: center !important;
|
|
31
|
+
gap: 0.5rem !important;
|
|
32
|
+
}
|
|
33
|
+
/* Selectbox label */
|
|
34
|
+
div[data-testid="stVerticalBlock"]:has(> .element-container .horizontal-marker) div[data-testid="stWidgetLabel"] {
|
|
35
|
+
margin-bottom: 0 !important;
|
|
36
|
+
padding-right: 0.5rem !important;
|
|
37
|
+
}
|
|
38
|
+
/* Selectbox input container */
|
|
39
|
+
div[data-testid="stVerticalBlock"]:has(> .element-container .horizontal-marker) div[data-baseweb="select"] {
|
|
40
|
+
min-width: 120px !important;
|
|
41
|
+
}
|
|
42
|
+
/* Selectbox dropdown */
|
|
43
|
+
div[data-testid="stVerticalBlock"]:has(> .element-container .horizontal-marker) div[role="listbox"] {
|
|
44
|
+
min-width: 120px !important;
|
|
45
|
+
}
|
|
46
|
+
</style>
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@contextmanager
|
|
51
|
+
def st_horizontal():
|
|
52
|
+
st.markdown(HORIZONTAL_STYLE, unsafe_allow_html=True)
|
|
53
|
+
with st.container():
|
|
54
|
+
st.markdown('<span class="hide-element horizontal-marker"></span>', unsafe_allow_html=True)
|
|
55
|
+
yield
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""
|
|
2
|
+
White patch detection methods for WSI processing
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from typing import Callable
|
|
6
|
+
|
|
7
|
+
import cv2
|
|
8
|
+
import numpy as np
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def is_white_patch_ptp(patch, white_ratio_threshold=0.9, rgb_range_threshold=20):
|
|
12
|
+
"""
|
|
13
|
+
Check if a patch is mostly white/blank using PTP (peak-to-peak) method
|
|
14
|
+
|
|
15
|
+
Args:
|
|
16
|
+
patch: RGB patch (H, W, 3)
|
|
17
|
+
white_ratio_threshold: Ratio threshold for white pixels (0-1)
|
|
18
|
+
rgb_range_threshold: Threshold for RGB range (max-min, 0-255)
|
|
19
|
+
|
|
20
|
+
Returns:
|
|
21
|
+
bool: True if patch is considered white/blank
|
|
22
|
+
"""
|
|
23
|
+
# white: RGB range (max-min) < rgb_range_threshold
|
|
24
|
+
rgb_range = np.ptp(patch, axis=2)
|
|
25
|
+
white_pixels = np.sum(rgb_range < rgb_range_threshold)
|
|
26
|
+
total_pixels = patch.shape[0] * patch.shape[1]
|
|
27
|
+
white_ratio_calculated = white_pixels / total_pixels
|
|
28
|
+
return white_ratio_calculated > white_ratio_threshold
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def is_white_patch_otsu(patch, white_ratio_threshold=0.8):
|
|
32
|
+
"""
|
|
33
|
+
Check if a patch is mostly white/blank using Otsu method
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
patch: RGB patch (H, W, 3)
|
|
37
|
+
white_ratio_threshold: Ratio threshold for white pixels (0-1)
|
|
38
|
+
|
|
39
|
+
Returns:
|
|
40
|
+
bool: True if patch is considered white/blank
|
|
41
|
+
"""
|
|
42
|
+
# Convert to grayscale and apply Otsu thresholding
|
|
43
|
+
gray = cv2.cvtColor(patch, cv2.COLOR_RGB2GRAY)
|
|
44
|
+
_, binary = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
|
|
45
|
+
white_pixels = np.sum(binary == 255)
|
|
46
|
+
total_pixels = patch.shape[0] * patch.shape[1]
|
|
47
|
+
white_ratio_calculated = white_pixels / total_pixels
|
|
48
|
+
return white_ratio_calculated > white_ratio_threshold
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def is_white_patch_std(patch, white_ratio_threshold=0.75, rgb_std_threshold=7.0):
|
|
52
|
+
"""
|
|
53
|
+
Check if a patch is mostly white/blank using STD method
|
|
54
|
+
|
|
55
|
+
Args:
|
|
56
|
+
patch: RGB patch (H, W, 3)
|
|
57
|
+
white_ratio_threshold: Ratio threshold for white pixels (0-1)
|
|
58
|
+
rgb_std_threshold: Threshold for RGB standard deviation (0-255)
|
|
59
|
+
|
|
60
|
+
Returns:
|
|
61
|
+
bool: True if patch is considered white/blank
|
|
62
|
+
"""
|
|
63
|
+
# white: RGB std < rgb_std_threshold
|
|
64
|
+
rgb_std_pixels = np.std(patch, axis=2) < rgb_std_threshold
|
|
65
|
+
white_pixels = np.sum(rgb_std_pixels)
|
|
66
|
+
total_pixels = patch.shape[0] * patch.shape[1]
|
|
67
|
+
white_ratio_calculated = white_pixels / total_pixels
|
|
68
|
+
return white_ratio_calculated > white_ratio_threshold
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def is_white_patch_green(patch, green_threshold=0.9):
|
|
72
|
+
"""
|
|
73
|
+
Check if a patch is mostly white/blank using green channel method
|
|
74
|
+
|
|
75
|
+
Args:
|
|
76
|
+
patch: RGB patch (H, W, 3)
|
|
77
|
+
green_threshold: Threshold for green channel mean
|
|
78
|
+
|
|
79
|
+
Returns:
|
|
80
|
+
bool: True if patch is considered white/blank
|
|
81
|
+
"""
|
|
82
|
+
# Extract green channel and normalize
|
|
83
|
+
green = patch[:, :, 1] / 255.0
|
|
84
|
+
green_mean = np.mean(green)
|
|
85
|
+
return green_mean > green_threshold
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def create_white_detector(method: str, threshold: float | None = None) -> Callable[[np.ndarray], bool]:
|
|
89
|
+
"""
|
|
90
|
+
Create white detection function from method name and threshold
|
|
91
|
+
|
|
92
|
+
Args:
|
|
93
|
+
method: Detection method ('ptp', 'otsu', 'std', 'green')
|
|
94
|
+
threshold: Threshold value (None for method-specific default)
|
|
95
|
+
|
|
96
|
+
Returns:
|
|
97
|
+
Function that takes (H, W, 3) numpy array and returns bool
|
|
98
|
+
|
|
99
|
+
Raises:
|
|
100
|
+
ValueError: If method is invalid
|
|
101
|
+
|
|
102
|
+
Example:
|
|
103
|
+
>>> detector = create_white_detector('ptp', 0.85)
|
|
104
|
+
>>> is_white = detector(patch) # patch is (256, 256, 3) array
|
|
105
|
+
"""
|
|
106
|
+
# Map method names to functions and default thresholds
|
|
107
|
+
method_map = {
|
|
108
|
+
"ptp": (is_white_patch_ptp, 0.9),
|
|
109
|
+
"otsu": (is_white_patch_otsu, 0.8),
|
|
110
|
+
"std": (is_white_patch_std, 0.75),
|
|
111
|
+
"green": (is_white_patch_green, 0.9),
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if method not in method_map:
|
|
115
|
+
raise ValueError(f"Invalid method '{method}'. Must be one of {list(method_map.keys())}")
|
|
116
|
+
|
|
117
|
+
func, default_threshold = method_map[method]
|
|
118
|
+
actual_threshold = threshold if threshold is not None else default_threshold
|
|
119
|
+
|
|
120
|
+
# Return curried function: patch -> bool
|
|
121
|
+
return lambda patch: func(patch, actual_threshold)
|
wsi_toolbox/watcher.py
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import os
|
|
3
|
+
import time
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Callable, Dict, Optional
|
|
6
|
+
|
|
7
|
+
from rich.console import Console
|
|
8
|
+
|
|
9
|
+
from . import commands
|
|
10
|
+
from .utils import plot_umap
|
|
11
|
+
|
|
12
|
+
DEFAULT_MODEL = os.getenv("DEFAULT_MODEL", "uni")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Status:
|
|
16
|
+
PROCESSING = "PROCESSING"
|
|
17
|
+
DONE = "DONE"
|
|
18
|
+
ERROR = "ERROR"
|
|
19
|
+
|
|
20
|
+
@classmethod
|
|
21
|
+
def is_processing_state(cls, status: str) -> bool:
|
|
22
|
+
"""状態が処理中系かどうかを判定"""
|
|
23
|
+
return status.startswith((cls.PROCESSING, cls.DONE, cls.ERROR))
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Task:
|
|
27
|
+
REQUEST_FILE = "_ROBIEMON.txt"
|
|
28
|
+
LOG_FILE = "_ROBIEMON_LOG.txt"
|
|
29
|
+
|
|
30
|
+
@staticmethod
|
|
31
|
+
def parse_request_line(line: str) -> tuple[str, bool]:
|
|
32
|
+
"""Parse the request line for model and rotation specifications.
|
|
33
|
+
Returns (model_name, should_rotate)"""
|
|
34
|
+
parts = [p.strip() for p in line.split(",")]
|
|
35
|
+
model_name = parts[0] if parts and parts[0] else DEFAULT_MODEL
|
|
36
|
+
should_rotate = len(parts) > 1 and parts[1].lower() == "rotate"
|
|
37
|
+
return model_name, should_rotate
|
|
38
|
+
|
|
39
|
+
def __init__(self, folder: Path, options_line: str, on_complete: Optional[Callable[[Path], None]] = None):
|
|
40
|
+
self.folder = folder
|
|
41
|
+
self.options_line = options_line
|
|
42
|
+
self.model_name, self.should_rotate = self.parse_request_line(options_line)
|
|
43
|
+
self.on_complete = on_complete
|
|
44
|
+
self.wsi_files = list(folder.glob("**/*.ndpi")) + list(folder.glob("**/*.svs"))
|
|
45
|
+
self.wsi_files.sort()
|
|
46
|
+
|
|
47
|
+
commands.set_default_progress("tqdm")
|
|
48
|
+
commands.set_default_model_preset(self.model_name)
|
|
49
|
+
|
|
50
|
+
def write_banner(self):
|
|
51
|
+
"""処理開始時のバナーをログに書き込み"""
|
|
52
|
+
self.append_log("=" * 50)
|
|
53
|
+
self.append_log(f"Processing folder: {self.folder}")
|
|
54
|
+
self.append_log(f"Request options: {self.options_line}")
|
|
55
|
+
self.append_log("Parsed options:")
|
|
56
|
+
self.append_log(f" - Model: {self.model_name} (default: {DEFAULT_MODEL})")
|
|
57
|
+
self.append_log(f" - Rotation: {'enabled' if self.should_rotate else 'disabled'}")
|
|
58
|
+
self.append_log(f"Found {len(self.wsi_files)} WSI files:")
|
|
59
|
+
for i, wsi_file in enumerate(self.wsi_files, 1):
|
|
60
|
+
size_mb = wsi_file.stat().st_size / (1024 * 1024)
|
|
61
|
+
self.append_log(f" {i}. {wsi_file.name} ({size_mb:.1f} MB)")
|
|
62
|
+
self.append_log("=" * 50)
|
|
63
|
+
|
|
64
|
+
def run(self):
|
|
65
|
+
try:
|
|
66
|
+
# ログファイルをクリア
|
|
67
|
+
with open(self.folder / self.LOG_FILE, "w") as f:
|
|
68
|
+
f.write("")
|
|
69
|
+
|
|
70
|
+
self.set_status(Status.PROCESSING)
|
|
71
|
+
self.write_banner()
|
|
72
|
+
|
|
73
|
+
# WSIファイルごとの処理
|
|
74
|
+
for i, wsi_file in enumerate(self.wsi_files):
|
|
75
|
+
try:
|
|
76
|
+
self.append_log(f"Processing [{i + 1}/{len(self.wsi_files)}]: {wsi_file.name}")
|
|
77
|
+
|
|
78
|
+
hdf5_tmp_path = wsi_file.with_suffix(".h5.tmp")
|
|
79
|
+
hdf5_file = wsi_file.with_suffix(".h5")
|
|
80
|
+
|
|
81
|
+
# HDF5変換(既存の場合はスキップ)
|
|
82
|
+
if not hdf5_file.exists():
|
|
83
|
+
self.append_log("Converting to HDF5...")
|
|
84
|
+
# Use new command pattern
|
|
85
|
+
commands.set_default_progress("tqdm")
|
|
86
|
+
cmd = commands.Wsi2HDF5Command(rotate=self.should_rotate)
|
|
87
|
+
_ = cmd(str(wsi_file), str(hdf5_tmp_path))
|
|
88
|
+
os.rename(hdf5_tmp_path, hdf5_file)
|
|
89
|
+
self.append_log("HDF5 conversion completed.")
|
|
90
|
+
|
|
91
|
+
# 特徴量抽出(既存の場合はスキップ)
|
|
92
|
+
self.append_log("Extracting features...")
|
|
93
|
+
# Use new command pattern
|
|
94
|
+
commands.set_default_device("cuda")
|
|
95
|
+
emb_cmd = commands.PatchEmbeddingCommand()
|
|
96
|
+
_ = emb_cmd(str(hdf5_file))
|
|
97
|
+
self.append_log("Feature extraction completed.")
|
|
98
|
+
|
|
99
|
+
# クラスタリングとUMAP生成
|
|
100
|
+
self.append_log("Starting clustering ...")
|
|
101
|
+
# Use new command pattern
|
|
102
|
+
cluster_cmd = commands.ClusteringCommand(resolution=1.0, use_umap=True)
|
|
103
|
+
_ = cluster_cmd([hdf5_file])
|
|
104
|
+
self.append_log("Clustering completed.")
|
|
105
|
+
|
|
106
|
+
base = str(wsi_file.with_suffix(""))
|
|
107
|
+
|
|
108
|
+
# UMAPプロット生成
|
|
109
|
+
self.append_log("Starting UMAP generation...")
|
|
110
|
+
umap_path = Path(f"{base}_umap.png")
|
|
111
|
+
if not umap_path.exists():
|
|
112
|
+
umap_embs = cluster_cmd.get_umap_embeddings()
|
|
113
|
+
fig = plot_umap(umap_embs, cluster_cmd.total_clusters)
|
|
114
|
+
fig.savefig(umap_path, bbox_inches="tight", pad_inches=0.5)
|
|
115
|
+
self.append_log(f"UMAP plot completed. Saved to {os.path.basename(umap_path)}")
|
|
116
|
+
else:
|
|
117
|
+
self.append_log("UMAP plot already exists. Skipped.")
|
|
118
|
+
|
|
119
|
+
# サムネイル生成
|
|
120
|
+
self.append_log("Starting thumbnail generation...")
|
|
121
|
+
thumb_path = Path(f"{base}_thumb.jpg")
|
|
122
|
+
if not thumb_path.exists():
|
|
123
|
+
# Use new command pattern
|
|
124
|
+
preview_cmd = commands.PreviewClustersCommand(size=64)
|
|
125
|
+
img = preview_cmd(str(hdf5_file), cluster_name="")
|
|
126
|
+
img.save(thumb_path)
|
|
127
|
+
self.append_log(f"Thumbnail generation completed. Saved to {thumb_path.name}")
|
|
128
|
+
else:
|
|
129
|
+
self.append_log("Thumbnail already exists. Skipped.")
|
|
130
|
+
|
|
131
|
+
self.append_log("=" * 30)
|
|
132
|
+
|
|
133
|
+
except Exception as e:
|
|
134
|
+
self.append_log(f"Error processing {wsi_file}: {str(e)}")
|
|
135
|
+
self.set_status(Status.ERROR)
|
|
136
|
+
if self.on_complete:
|
|
137
|
+
self.on_complete(self.folder)
|
|
138
|
+
return
|
|
139
|
+
|
|
140
|
+
self.set_status(Status.DONE)
|
|
141
|
+
self.append_log("All processing completed successfully")
|
|
142
|
+
|
|
143
|
+
except Exception as e:
|
|
144
|
+
self.append_log(f"Error: {str(e)}")
|
|
145
|
+
|
|
146
|
+
if self.on_complete:
|
|
147
|
+
self.on_complete(self.folder)
|
|
148
|
+
|
|
149
|
+
def set_status(self, status: str):
|
|
150
|
+
self.status = status
|
|
151
|
+
with open(self.folder / self.REQUEST_FILE, "w") as f:
|
|
152
|
+
f.write(f"{status}\n")
|
|
153
|
+
|
|
154
|
+
def append_log(self, message: str):
|
|
155
|
+
with open(self.folder / self.LOG_FILE, "a") as f:
|
|
156
|
+
f.write(message + "\n")
|
|
157
|
+
print(message)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
class Watcher:
|
|
161
|
+
def __init__(self, base_dir: str):
|
|
162
|
+
self.base_dir = Path(base_dir)
|
|
163
|
+
self.running_tasks: Dict[Path, Task] = {}
|
|
164
|
+
self.console = Console()
|
|
165
|
+
|
|
166
|
+
def run(self, interval: int = 60):
|
|
167
|
+
self.console.print("\n[bold blue]ROBIEMON Watcher started[/]")
|
|
168
|
+
self.console.print(f"[blue]Watching directory:[/] {self.base_dir}")
|
|
169
|
+
self.console.print(f"[blue]Polling interval:[/] {interval} seconds")
|
|
170
|
+
self.console.print("[yellow]Press Ctrl+C to stop[/]\n")
|
|
171
|
+
|
|
172
|
+
while True:
|
|
173
|
+
try:
|
|
174
|
+
self.check_folders()
|
|
175
|
+
|
|
176
|
+
# カウントダウン表示
|
|
177
|
+
for remaining in range(interval, 0, -1):
|
|
178
|
+
print(f"\rNext check in {remaining:2d}s", end="", flush=True)
|
|
179
|
+
time.sleep(1)
|
|
180
|
+
# カウントダウン終了後、同じ行を再利用
|
|
181
|
+
print("\rNext check in 0s", end="", flush=True)
|
|
182
|
+
|
|
183
|
+
except KeyboardInterrupt:
|
|
184
|
+
self.console.print("\n[yellow]Stopping watcher...[/]")
|
|
185
|
+
break
|
|
186
|
+
except Exception as e:
|
|
187
|
+
self.console.print(f"[red]ERROR:[/] {str(e)}")
|
|
188
|
+
|
|
189
|
+
def check_folders(self):
|
|
190
|
+
for folder in self.base_dir.rglob("*"):
|
|
191
|
+
if not folder.is_dir():
|
|
192
|
+
continue
|
|
193
|
+
|
|
194
|
+
request_file = folder / Task.REQUEST_FILE
|
|
195
|
+
if not request_file.exists():
|
|
196
|
+
continue
|
|
197
|
+
|
|
198
|
+
if folder in self.running_tasks:
|
|
199
|
+
continue
|
|
200
|
+
|
|
201
|
+
try:
|
|
202
|
+
with open(request_file, "r") as f:
|
|
203
|
+
content = f.read()
|
|
204
|
+
if not content.strip():
|
|
205
|
+
continue
|
|
206
|
+
|
|
207
|
+
# First line contains model/rotation specs
|
|
208
|
+
options_line = content.split("\n")[0].strip()
|
|
209
|
+
|
|
210
|
+
# Original status check from the entire file
|
|
211
|
+
status = content.strip()
|
|
212
|
+
|
|
213
|
+
except Exception as e:
|
|
214
|
+
print(f"Error reading {request_file}: {e}")
|
|
215
|
+
continue
|
|
216
|
+
|
|
217
|
+
if Status.is_processing_state(status):
|
|
218
|
+
continue
|
|
219
|
+
|
|
220
|
+
# \rを含むログから改行するため空白行を挿入
|
|
221
|
+
print()
|
|
222
|
+
print()
|
|
223
|
+
print(f"detected: {folder}")
|
|
224
|
+
print(f"Request options: {options_line}")
|
|
225
|
+
|
|
226
|
+
task = Task(folder, options_line, on_complete=lambda f: self.running_tasks.pop(f, None))
|
|
227
|
+
self.running_tasks[folder] = task
|
|
228
|
+
task.run() # 同期実行に変更
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
BASE_DIR = os.getenv("BASE_DIR", "data")
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def main():
|
|
235
|
+
parser = argparse.ArgumentParser(description="ROBIEMON WSI Processor Watcher")
|
|
236
|
+
parser.add_argument(
|
|
237
|
+
"--base-dir", type=str, default=BASE_DIR, help="Base directory to watch for WSI processing requests"
|
|
238
|
+
)
|
|
239
|
+
parser.add_argument("--interval", type=int, default=60, help="Polling interval in seconds (default: 60)")
|
|
240
|
+
|
|
241
|
+
args = parser.parse_args()
|
|
242
|
+
|
|
243
|
+
base_dir = Path(args.base_dir)
|
|
244
|
+
if not base_dir.exists():
|
|
245
|
+
print(f"Error: Base directory '{args.base_dir}' does not exist")
|
|
246
|
+
return
|
|
247
|
+
if not base_dir.is_dir():
|
|
248
|
+
print(f"Error: '{args.base_dir}' is not a directory")
|
|
249
|
+
return
|
|
250
|
+
|
|
251
|
+
watcher = Watcher(args.base_dir)
|
|
252
|
+
watcher.run(interval=args.interval) # asyncio.runを削除
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
if __name__ == "__main__":
|
|
256
|
+
main()
|