wsi-toolbox 0.1.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 +119 -0
- wsi_toolbox/app.py +753 -0
- wsi_toolbox/cli.py +485 -0
- wsi_toolbox/commands/__init__.py +92 -0
- wsi_toolbox/commands/clustering.py +214 -0
- wsi_toolbox/commands/dzi_export.py +202 -0
- wsi_toolbox/commands/patch_embedding.py +199 -0
- wsi_toolbox/commands/preview.py +335 -0
- wsi_toolbox/commands/wsi.py +196 -0
- wsi_toolbox/exp.py +466 -0
- wsi_toolbox/models.py +38 -0
- wsi_toolbox/utils/__init__.py +153 -0
- wsi_toolbox/utils/analysis.py +127 -0
- wsi_toolbox/utils/cli.py +25 -0
- wsi_toolbox/utils/helpers.py +57 -0
- wsi_toolbox/utils/progress.py +206 -0
- wsi_toolbox/utils/seed.py +21 -0
- wsi_toolbox/utils/st.py +53 -0
- wsi_toolbox/watcher.py +261 -0
- wsi_toolbox/wsi_files.py +187 -0
- wsi_toolbox-0.1.0.dist-info/METADATA +269 -0
- wsi_toolbox-0.1.0.dist-info/RECORD +25 -0
- wsi_toolbox-0.1.0.dist-info/WHEEL +4 -0
- wsi_toolbox-0.1.0.dist-info/entry_points.txt +2 -0
- wsi_toolbox-0.1.0.dist-info/licenses/LICENSE +21 -0
wsi_toolbox/app.py
ADDED
|
@@ -0,0 +1,753 @@
|
|
|
1
|
+
import re
|
|
2
|
+
import time
|
|
3
|
+
import os
|
|
4
|
+
import warnings
|
|
5
|
+
from pathlib import Path as P
|
|
6
|
+
import sys
|
|
7
|
+
from enum import Enum, auto
|
|
8
|
+
from typing import Optional, List, Dict, Any, Tuple
|
|
9
|
+
from datetime import datetime
|
|
10
|
+
|
|
11
|
+
import numpy as np
|
|
12
|
+
from PIL import Image
|
|
13
|
+
import h5py
|
|
14
|
+
import torch
|
|
15
|
+
import pandas as pd
|
|
16
|
+
from st_aggrid import AgGrid, GridOptionsBuilder, JsCode, GridUpdateMode
|
|
17
|
+
from pydantic import BaseModel, Field
|
|
18
|
+
|
|
19
|
+
torch.classes.__path__ = []
|
|
20
|
+
import streamlit as st
|
|
21
|
+
|
|
22
|
+
sys.path.append(str(P(__file__).parent))
|
|
23
|
+
__package__ = 'wsi_toolbox'
|
|
24
|
+
|
|
25
|
+
from .models import MODEL_LABELS, MODEL_NAMES_BY_LABEL
|
|
26
|
+
from .utils.progress import tqdm_or_st
|
|
27
|
+
from .utils.st import st_horizontal
|
|
28
|
+
from .utils import plot_umap
|
|
29
|
+
from . import commands
|
|
30
|
+
|
|
31
|
+
# Suppress warnings
|
|
32
|
+
# sklearn 1.6+ internal deprecation warning
|
|
33
|
+
warnings.filterwarnings('ignore', category=FutureWarning, message='.*force_all_finite.*')
|
|
34
|
+
# timm library internal torch.load warning
|
|
35
|
+
warnings.filterwarnings('ignore', category=FutureWarning, message="You are using `torch.load` with `weights_only=False`")
|
|
36
|
+
|
|
37
|
+
commands.set_default_progress('streamlit')
|
|
38
|
+
commands.set_default_device('cuda')
|
|
39
|
+
|
|
40
|
+
Image.MAX_IMAGE_PIXELS = 3_500_000_000
|
|
41
|
+
|
|
42
|
+
BASE_DIR = os.getenv('BASE_DIR', 'data')
|
|
43
|
+
DEFAULT_MODEL = os.getenv('DEFAULT_MODEL', 'uni')
|
|
44
|
+
|
|
45
|
+
# Global constants
|
|
46
|
+
BATCH_SIZE = 256
|
|
47
|
+
PATCH_SIZE = 256
|
|
48
|
+
THUMBNAIL_SIZE = 64
|
|
49
|
+
DEFAULT_CLUSTER_RESOLUTION = 1.0
|
|
50
|
+
MAX_CLUSTER_RESOLUTION = 3.0
|
|
51
|
+
MIN_CLUSTER_RESOLUTION = 0.0
|
|
52
|
+
CLUSTER_RESOLUTION_STEP = 0.1
|
|
53
|
+
|
|
54
|
+
# File type definitions
|
|
55
|
+
class FileType:
|
|
56
|
+
EMPTY = 'empty'
|
|
57
|
+
MIX = 'mix'
|
|
58
|
+
DIRECTORY = 'directory'
|
|
59
|
+
WSI = 'wsi'
|
|
60
|
+
HDF5 = 'hdf5'
|
|
61
|
+
IMAGE = 'image'
|
|
62
|
+
OTHER = 'other'
|
|
63
|
+
|
|
64
|
+
FILE_TYPE_CONFIG = {
|
|
65
|
+
# FileType.EMPTY: {
|
|
66
|
+
# 'label': '空',
|
|
67
|
+
# 'icon': '🔳',
|
|
68
|
+
# },
|
|
69
|
+
FileType.DIRECTORY: {
|
|
70
|
+
'label': 'フォルダ',
|
|
71
|
+
'icon': '📁',
|
|
72
|
+
},
|
|
73
|
+
FileType.WSI: {
|
|
74
|
+
'label': 'WSI',
|
|
75
|
+
'icon': '🔬',
|
|
76
|
+
'extensions': {'.ndpi', '.svs'},
|
|
77
|
+
},
|
|
78
|
+
FileType.HDF5: {
|
|
79
|
+
'label': 'HDF5',
|
|
80
|
+
'icon': '📊',
|
|
81
|
+
'extensions': {'.h5'},
|
|
82
|
+
},
|
|
83
|
+
FileType.IMAGE: {
|
|
84
|
+
'label': '画像',
|
|
85
|
+
'icon': '🖼️',
|
|
86
|
+
'extensions': {'.bmp', '.gif', '.icns', '.ico', '.jpg', '.jpeg', '.png', '.tif', '.tiff'},
|
|
87
|
+
},
|
|
88
|
+
FileType.OTHER: {
|
|
89
|
+
'label': 'その他',
|
|
90
|
+
'icon': '📄',
|
|
91
|
+
},
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
def get_file_type(path: P) -> str:
|
|
95
|
+
"""ファイルパスからファイルタイプを判定する"""
|
|
96
|
+
if path.is_dir():
|
|
97
|
+
return FileType.DIRECTORY
|
|
98
|
+
|
|
99
|
+
ext = path.suffix.lower()
|
|
100
|
+
for type_key, config in FILE_TYPE_CONFIG.items():
|
|
101
|
+
if 'extensions' in config and ext in config['extensions']:
|
|
102
|
+
return type_key
|
|
103
|
+
|
|
104
|
+
return FileType.OTHER
|
|
105
|
+
|
|
106
|
+
def get_file_type_display(type_key: str) -> str:
|
|
107
|
+
"""ファイルタイプの表示用ラベルとアイコンを取得する"""
|
|
108
|
+
config = FILE_TYPE_CONFIG.get(type_key, FILE_TYPE_CONFIG[FileType.OTHER])
|
|
109
|
+
return f"{config['icon']} {config['label']}"
|
|
110
|
+
|
|
111
|
+
def add_beforeunload_js():
|
|
112
|
+
js = """
|
|
113
|
+
<script>
|
|
114
|
+
window.onbeforeunload = function(e) {
|
|
115
|
+
if (window.localStorage.getItem('streamlit_locked') === 'true') {
|
|
116
|
+
e.preventDefault();
|
|
117
|
+
e.returnValue = "処理中にページを離れると処理がリセットされます。ページを離れますか?";
|
|
118
|
+
return e.returnValue;
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
</script>
|
|
122
|
+
"""
|
|
123
|
+
st.components.v1.html(js, height=0)
|
|
124
|
+
|
|
125
|
+
def set_locked_state(is_locked):
|
|
126
|
+
print('locked', is_locked)
|
|
127
|
+
st.session_state.locked = is_locked
|
|
128
|
+
js = f"""
|
|
129
|
+
<script>
|
|
130
|
+
window.localStorage.setItem('streamlit_locked', '{str(is_locked).lower()}');
|
|
131
|
+
</script>
|
|
132
|
+
"""
|
|
133
|
+
st.components.v1.html(js, height=0)
|
|
134
|
+
|
|
135
|
+
def lock():
|
|
136
|
+
set_locked_state(True)
|
|
137
|
+
|
|
138
|
+
def unlock():
|
|
139
|
+
set_locked_state(False)
|
|
140
|
+
|
|
141
|
+
st.set_page_config(
|
|
142
|
+
page_title='WSI Analysis System',
|
|
143
|
+
page_icon='🔬',
|
|
144
|
+
layout='wide'
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
STATUS_READY = 0
|
|
148
|
+
STATUS_BLOCKED = 1
|
|
149
|
+
STATUS_UNSUPPORTED = 2
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def render_reset_button():
|
|
153
|
+
if st.button('リセットする', on_click=unlock):
|
|
154
|
+
st.rerun()
|
|
155
|
+
|
|
156
|
+
def render_navigation(current_dir_abs, default_root_abs):
|
|
157
|
+
"""Render navigation buttons for moving between directories."""
|
|
158
|
+
with st_horizontal():
|
|
159
|
+
if current_dir_abs == default_root_abs:
|
|
160
|
+
st.button('↑ 親フォルダへ', disabled=True)
|
|
161
|
+
else:
|
|
162
|
+
if st.button('↑ 親フォルダへ', disabled=st.session_state.locked):
|
|
163
|
+
parent_dir = os.path.dirname(current_dir_abs)
|
|
164
|
+
if os.path.commonpath([default_root_abs]) == os.path.commonpath([default_root_abs, parent_dir]):
|
|
165
|
+
st.session_state.current_dir = parent_dir
|
|
166
|
+
st.rerun()
|
|
167
|
+
if st.button('フォルダ更新', disabled=st.session_state.locked):
|
|
168
|
+
st.rerun()
|
|
169
|
+
|
|
170
|
+
model_label = MODEL_LABELS[st.session_state.model]
|
|
171
|
+
new_model_label = st.selectbox(
|
|
172
|
+
'使用モデル',
|
|
173
|
+
list(MODEL_LABELS.values()),
|
|
174
|
+
index=list(MODEL_LABELS.values()).index(model_label),
|
|
175
|
+
disabled=st.session_state.locked
|
|
176
|
+
)
|
|
177
|
+
new_model = MODEL_NAMES_BY_LABEL[new_model_label]
|
|
178
|
+
|
|
179
|
+
# モデルが変更された場合、即座にリロード
|
|
180
|
+
if new_model != st.session_state.model:
|
|
181
|
+
print('model changed', st.session_state.model, '->', new_model)
|
|
182
|
+
st.session_state.model = new_model
|
|
183
|
+
st.rerun()
|
|
184
|
+
|
|
185
|
+
class HDF5Detail(BaseModel):
|
|
186
|
+
status: int
|
|
187
|
+
has_features: bool
|
|
188
|
+
cluster_names: List[str]
|
|
189
|
+
patch_count: int
|
|
190
|
+
mpp: float
|
|
191
|
+
cols: int
|
|
192
|
+
rows: int
|
|
193
|
+
desc: Optional[str] = None
|
|
194
|
+
cluster_ids_by_name: Dict[str, List[int]]
|
|
195
|
+
|
|
196
|
+
class FileEntry(BaseModel):
|
|
197
|
+
name: str
|
|
198
|
+
path: str
|
|
199
|
+
type: str
|
|
200
|
+
size: int
|
|
201
|
+
modified: datetime
|
|
202
|
+
detail: Optional[HDF5Detail] = None
|
|
203
|
+
|
|
204
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
205
|
+
"""AG Grid用の辞書に変換"""
|
|
206
|
+
return {
|
|
207
|
+
'name': self.name,
|
|
208
|
+
'path': self.path,
|
|
209
|
+
'type': self.type,
|
|
210
|
+
'size': self.size,
|
|
211
|
+
'modified': self.modified,
|
|
212
|
+
'detail': self.detail.model_dump() if self.detail else None
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def get_hdf5_detail(hdf_path) -> Optional[HDF5Detail]:
|
|
217
|
+
try:
|
|
218
|
+
model_name = st.session_state.model
|
|
219
|
+
with h5py.File(hdf_path, 'r') as f:
|
|
220
|
+
if 'metadata/patch_count' not in f:
|
|
221
|
+
return HDF5Detail(
|
|
222
|
+
status=STATUS_UNSUPPORTED,
|
|
223
|
+
has_features=False,
|
|
224
|
+
cluster_names=['未施行'],
|
|
225
|
+
patch_count=0,
|
|
226
|
+
mpp=0,
|
|
227
|
+
cols=0,
|
|
228
|
+
rows=0,
|
|
229
|
+
cluster_ids_by_name={},
|
|
230
|
+
)
|
|
231
|
+
patch_count = f['metadata/patch_count'][()]
|
|
232
|
+
has_features = (f'{model_name}/features' in f) and (len(f[f'{model_name}/features']) == patch_count)
|
|
233
|
+
cluster_names = ['未施行']
|
|
234
|
+
if model_name in f:
|
|
235
|
+
cluster_names = [
|
|
236
|
+
k.replace('clusters_', '').replace('clusters', 'デフォルト')
|
|
237
|
+
for k in f[model_name].keys() if re.match(r'^clusters.*', k)
|
|
238
|
+
]
|
|
239
|
+
cluster_names = [n for n in cluster_names if '-' not in n]
|
|
240
|
+
cluster_ids_by_name = {}
|
|
241
|
+
for c in cluster_names:
|
|
242
|
+
k = 'clusters' if c == 'デフォルト' else f'clusters_{c}'
|
|
243
|
+
k = f'{st.session_state.model}/{k}'
|
|
244
|
+
if k in f :
|
|
245
|
+
ids = np.unique(f[k][()]).tolist()
|
|
246
|
+
cluster_ids_by_name[c] = ids
|
|
247
|
+
return HDF5Detail(
|
|
248
|
+
status=STATUS_READY,
|
|
249
|
+
has_features=has_features,
|
|
250
|
+
cluster_names=cluster_names,
|
|
251
|
+
patch_count=patch_count,
|
|
252
|
+
mpp=f['metadata/mpp'][()],
|
|
253
|
+
cols=f['metadata/cols'][()],
|
|
254
|
+
rows=f['metadata/rows'][()],
|
|
255
|
+
cluster_ids_by_name=cluster_ids_by_name,
|
|
256
|
+
)
|
|
257
|
+
except BlockingIOError:
|
|
258
|
+
return HDF5Detail(
|
|
259
|
+
status=STATUS_BLOCKED,
|
|
260
|
+
has_features=False,
|
|
261
|
+
cluster_names=[''],
|
|
262
|
+
patch_count=0,
|
|
263
|
+
mpp=0,
|
|
264
|
+
cols=0,
|
|
265
|
+
rows=0,
|
|
266
|
+
desc='他システムで処理中',
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
def list_files(directory) -> List[FileEntry]:
|
|
270
|
+
files = []
|
|
271
|
+
directories = []
|
|
272
|
+
|
|
273
|
+
for item in sorted(os.listdir(directory)):
|
|
274
|
+
item_path = P(os.path.join(directory, item))
|
|
275
|
+
file_type = get_file_type(item_path)
|
|
276
|
+
type_config = FILE_TYPE_CONFIG[file_type]
|
|
277
|
+
|
|
278
|
+
if file_type == FileType.DIRECTORY:
|
|
279
|
+
directories.append(FileEntry(
|
|
280
|
+
name=f"{type_config['icon']} {item}",
|
|
281
|
+
path=str(item_path),
|
|
282
|
+
type=file_type,
|
|
283
|
+
size=0,
|
|
284
|
+
modified=pd.to_datetime(os.path.getmtime(item_path), unit='s'),
|
|
285
|
+
detail=None
|
|
286
|
+
))
|
|
287
|
+
continue
|
|
288
|
+
|
|
289
|
+
detail = None
|
|
290
|
+
if file_type == FileType.HDF5:
|
|
291
|
+
detail = get_hdf5_detail(str(item_path))
|
|
292
|
+
|
|
293
|
+
exists = item_path.exists()
|
|
294
|
+
|
|
295
|
+
files.append(FileEntry(
|
|
296
|
+
name=f"{type_config['icon']} {item}",
|
|
297
|
+
path=str(item_path),
|
|
298
|
+
type=file_type,
|
|
299
|
+
size=os.path.getsize(item_path) if exists else 0,
|
|
300
|
+
modified=pd.to_datetime(os.path.getmtime(item_path), unit='s') if exists else 0,
|
|
301
|
+
detail=detail
|
|
302
|
+
))
|
|
303
|
+
|
|
304
|
+
all_items = directories + files
|
|
305
|
+
return all_items
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def render_file_list(files: List[FileEntry]) -> List[FileEntry]:
|
|
309
|
+
"""ファイル一覧をAG Gridで表示し、選択されたファイルを返します"""
|
|
310
|
+
if not files:
|
|
311
|
+
st.warning('ファイルが選択されていません')
|
|
312
|
+
return []
|
|
313
|
+
|
|
314
|
+
# FileEntryのリストを辞書のリストに変換し、DataFrameに変換
|
|
315
|
+
data = [entry.to_dict() for entry in files]
|
|
316
|
+
df = pd.DataFrame(data)
|
|
317
|
+
|
|
318
|
+
# グリッドの設定
|
|
319
|
+
gb = GridOptionsBuilder.from_dataframe(df)
|
|
320
|
+
|
|
321
|
+
# カラム設定
|
|
322
|
+
gb.configure_column(
|
|
323
|
+
'name',
|
|
324
|
+
header_name='ファイル名',
|
|
325
|
+
width=300,
|
|
326
|
+
sortable=True,
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
gb.configure_column(
|
|
330
|
+
'type',
|
|
331
|
+
header_name='種別',
|
|
332
|
+
width=100,
|
|
333
|
+
filter='agSetColumnFilter',
|
|
334
|
+
sortable=True,
|
|
335
|
+
valueGetter=JsCode("""
|
|
336
|
+
function(params) {
|
|
337
|
+
const type = params.data.type;
|
|
338
|
+
const config = {
|
|
339
|
+
'directory': { label: 'フォルダ' },
|
|
340
|
+
'wsi': { label: 'WSI' },
|
|
341
|
+
'hdf5': { label: 'HDF5' },
|
|
342
|
+
'image': { label: '画像' },
|
|
343
|
+
'other': { label: 'その他' }
|
|
344
|
+
};
|
|
345
|
+
const typeConfig = config[type] || config['other'];
|
|
346
|
+
return typeConfig.label;
|
|
347
|
+
}
|
|
348
|
+
""")
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
gb.configure_column(
|
|
352
|
+
'size',
|
|
353
|
+
header_name='ファイルサイズ',
|
|
354
|
+
width=120,
|
|
355
|
+
sortable=True,
|
|
356
|
+
valueGetter=JsCode("""
|
|
357
|
+
function(params) {
|
|
358
|
+
const size = params.data.size;
|
|
359
|
+
if (size === 0) return '';
|
|
360
|
+
if (size < 1024) return size + ' B';
|
|
361
|
+
if (size < 1024 * 1024) return (size / 1024).toFixed() + ' KB';
|
|
362
|
+
if (size < 1024 * 1024 * 1024) return (size / (1024 * 1024)).toFixed() + ' MB';
|
|
363
|
+
return (size / (1024 * 1024 * 1024)).toFixed() + ' GB';
|
|
364
|
+
}
|
|
365
|
+
""")
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
gb.configure_column(
|
|
369
|
+
'modified',
|
|
370
|
+
header_name='最終更新',
|
|
371
|
+
width=180,
|
|
372
|
+
type=['dateColumnFilter', 'customDateTimeFormat'],
|
|
373
|
+
custom_format_string='yyyy/MM/dd HH:mm:ss',
|
|
374
|
+
sortable=True
|
|
375
|
+
)
|
|
376
|
+
|
|
377
|
+
# 内部カラムを非表示
|
|
378
|
+
gb.configure_column('path', hide=True)
|
|
379
|
+
gb.configure_column('detail', hide=True)
|
|
380
|
+
|
|
381
|
+
# 選択設定
|
|
382
|
+
gb.configure_selection(
|
|
383
|
+
selection_mode="multiple",
|
|
384
|
+
use_checkbox=True,
|
|
385
|
+
header_checkbox=True,
|
|
386
|
+
pre_selected_rows=[]
|
|
387
|
+
)
|
|
388
|
+
|
|
389
|
+
# グリッドオプションの構築
|
|
390
|
+
grid_options = gb.build()
|
|
391
|
+
|
|
392
|
+
# AG Gridの表示
|
|
393
|
+
grid_response = AgGrid(
|
|
394
|
+
df,
|
|
395
|
+
gridOptions=grid_options,
|
|
396
|
+
height=400,
|
|
397
|
+
fit_columns_on_grid_load=True,
|
|
398
|
+
allow_unsafe_jscode=True,
|
|
399
|
+
theme='streamlit',
|
|
400
|
+
enable_enterprise_modules=False,
|
|
401
|
+
update_mode=GridUpdateMode.SELECTION_CHANGED,
|
|
402
|
+
reload_data=True
|
|
403
|
+
)
|
|
404
|
+
|
|
405
|
+
selected_rows = grid_response['selected_rows']
|
|
406
|
+
if selected_rows is None:
|
|
407
|
+
return []
|
|
408
|
+
|
|
409
|
+
selected_files = [files[int(i)] for i in selected_rows.index]
|
|
410
|
+
return selected_files
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def render_mode_wsi(files: List[FileEntry], selected_files: List[FileEntry]):
|
|
414
|
+
"""Render UI for WSI processing mode."""
|
|
415
|
+
model_label = MODEL_LABELS[st.session_state.model]
|
|
416
|
+
|
|
417
|
+
st.subheader('WSIをパッチ分割し特徴量を抽出する', divider=True)
|
|
418
|
+
st.write(f'分割したパッチをHDF5に保存し、{model_label}特徴量抽出を実行します。それぞれ5分、20分程度かかります。')
|
|
419
|
+
|
|
420
|
+
do_clustering = st.checkbox('クラスタリングも実行する', value=True, disabled=st.session_state.locked)
|
|
421
|
+
rotate = st.checkbox('画像を回転させる(WSIファイルのビュアーの画面から回転された状態で処理します)', value=True, disabled=st.session_state.locked)
|
|
422
|
+
|
|
423
|
+
hdf5_paths = []
|
|
424
|
+
if st.button('処理を実行', disabled=st.session_state.locked, on_click=lock):
|
|
425
|
+
set_locked_state(True)
|
|
426
|
+
st.write(f'WSIから画像をパッチ分割しHDF5ファイルを構築します。')
|
|
427
|
+
with st.container(border=True):
|
|
428
|
+
for i, f in enumerate(selected_files):
|
|
429
|
+
st.write(f'**[{i+1}/{len(selected_files)}] 処理中のWSIファイル: {f.name}**')
|
|
430
|
+
wsi_path = f.path
|
|
431
|
+
p = P(wsi_path)
|
|
432
|
+
hdf5_path = str(p.with_suffix('.h5'))
|
|
433
|
+
hdf5_tmp_path = str(p.with_suffix('.h5.tmp'))
|
|
434
|
+
|
|
435
|
+
# 既存のHDF5ファイルを検索
|
|
436
|
+
matched_h5_entry = next((f for f in files if f.path == hdf5_path), None)
|
|
437
|
+
if matched_h5_entry is not None and matched_h5_entry.detail and matched_h5_entry.detail.status == STATUS_READY:
|
|
438
|
+
st.write(f'すでにHDF5ファイル({os.path.basename(hdf5_path)})が存在しているので分割処理をスキップしました。')
|
|
439
|
+
else:
|
|
440
|
+
with st.spinner('WSIを分割しHDF5ファイルを構成しています...', show_time=True):
|
|
441
|
+
# Use new command pattern
|
|
442
|
+
cmd = commands.Wsi2HDF5Command(patch_size=PATCH_SIZE, rotate=rotate)
|
|
443
|
+
result = cmd(wsi_path, hdf5_tmp_path)
|
|
444
|
+
os.rename(hdf5_tmp_path, hdf5_path)
|
|
445
|
+
st.write('HDF5ファイルに変換完了。')
|
|
446
|
+
|
|
447
|
+
if matched_h5_entry is not None and matched_h5_entry.detail and matched_h5_entry.detail.has_features:
|
|
448
|
+
st.write(f'すでに{model_label}特徴量を抽出済みなので処理をスキップしました。')
|
|
449
|
+
else:
|
|
450
|
+
with st.spinner(f'{model_label}特徴量を抽出中...', show_time=True):
|
|
451
|
+
# Use new command pattern
|
|
452
|
+
commands.set_default_model(st.session_state.model)
|
|
453
|
+
cmd = commands.PatchEmbeddingCommand(batch_size=BATCH_SIZE, overwrite=True)
|
|
454
|
+
result = cmd(hdf5_path)
|
|
455
|
+
st.write(f'{model_label}特徴量の抽出完了。')
|
|
456
|
+
hdf5_paths.append(hdf5_path)
|
|
457
|
+
if i < len(selected_files)-1:
|
|
458
|
+
st.divider()
|
|
459
|
+
|
|
460
|
+
if do_clustering:
|
|
461
|
+
st.write(f'クラスタリングを行います。')
|
|
462
|
+
with st.container(border=True):
|
|
463
|
+
for i, (f, hdf5_path) in enumerate(zip(selected_files, hdf5_paths)):
|
|
464
|
+
st.write(f'**[{i+1}/{len(selected_files)}] 処理ファイル: {f.name}**')
|
|
465
|
+
base, ext = os.path.splitext(f.path)
|
|
466
|
+
umap_path = f'{base}_umap.png'
|
|
467
|
+
thumb_path = f'{base}_thumb.jpg'
|
|
468
|
+
with st.spinner(f'クラスタリング中...', show_time=True):
|
|
469
|
+
# Use new command pattern
|
|
470
|
+
commands.set_default_model(st.session_state.model)
|
|
471
|
+
cmd = commands.ClusteringCommand(
|
|
472
|
+
resolution=DEFAULT_CLUSTER_RESOLUTION,
|
|
473
|
+
cluster_name='',
|
|
474
|
+
use_umap=True
|
|
475
|
+
)
|
|
476
|
+
result = cmd([hdf5_path])
|
|
477
|
+
|
|
478
|
+
# Plot UMAP
|
|
479
|
+
umap_embs = cmd.get_umap_embeddings()
|
|
480
|
+
fig = plot_umap(umap_embs, cmd.total_clusters)
|
|
481
|
+
fig.savefig(umap_path, bbox_inches='tight', pad_inches=0.5)
|
|
482
|
+
st.write(f'クラスタリング結果を{os.path.basename(umap_path)}に出力しました。')
|
|
483
|
+
|
|
484
|
+
with st.spinner('オーバービュー生成中', show_time=True):
|
|
485
|
+
# Use new command pattern
|
|
486
|
+
commands.set_default_model(st.session_state.model)
|
|
487
|
+
preview_cmd = commands.PreviewClustersCommand(size=THUMBNAIL_SIZE)
|
|
488
|
+
img = preview_cmd(hdf5_path, cluster_name='')
|
|
489
|
+
img.save(thumb_path)
|
|
490
|
+
st.write(f'オーバービューを{os.path.basename(thumb_path)}に出力しました。')
|
|
491
|
+
if i < len(selected_files)-1:
|
|
492
|
+
st.divider()
|
|
493
|
+
|
|
494
|
+
st.write('すべての処理が完了しました。')
|
|
495
|
+
render_reset_button()
|
|
496
|
+
|
|
497
|
+
def render_mode_hdf5(selected_files: List[FileEntry]):
|
|
498
|
+
"""Render UI for HDF5 analysis mode."""
|
|
499
|
+
model_label = MODEL_LABELS[st.session_state.model]
|
|
500
|
+
st.subheader('HDF5ファイル解析オプション', divider=True)
|
|
501
|
+
|
|
502
|
+
# 選択されたファイルの詳細情報を取得
|
|
503
|
+
details = [
|
|
504
|
+
{'name': f.name, **f.detail.model_dump()}
|
|
505
|
+
for f in selected_files
|
|
506
|
+
if f.detail
|
|
507
|
+
]
|
|
508
|
+
df_details = pd.DataFrame(details)
|
|
509
|
+
|
|
510
|
+
if len(set(df_details['status'])) > 1:
|
|
511
|
+
st.error('サポートされていないHDF5ファイルが含まれています。')
|
|
512
|
+
return
|
|
513
|
+
if np.all(df_details['status'] == STATUS_UNSUPPORTED):
|
|
514
|
+
st.error('サポートされていないHDF5ファイルが選択されました。')
|
|
515
|
+
return
|
|
516
|
+
if np.all(df_details['status'] == STATUS_BLOCKED):
|
|
517
|
+
st.error('他システムで使用されています。')
|
|
518
|
+
return
|
|
519
|
+
if not np.all(df_details['status'] == STATUS_READY):
|
|
520
|
+
st.error('不明な状態です。')
|
|
521
|
+
return
|
|
522
|
+
|
|
523
|
+
df_details['has_features'] = df_details['has_features'].map({True: '抽出済み', False: '未抽出'})
|
|
524
|
+
st.dataframe(
|
|
525
|
+
df_details,
|
|
526
|
+
column_config={
|
|
527
|
+
'name': 'ファイル名',
|
|
528
|
+
'has_features': '特徴量抽出状況',
|
|
529
|
+
'cluster_names': 'クラスタリング処理状況',
|
|
530
|
+
'patch_count': 'パッチ数',
|
|
531
|
+
'mpp': 'micro/pixel',
|
|
532
|
+
'status': None,
|
|
533
|
+
'desc': None,
|
|
534
|
+
'cluster_ids_by_name': None,
|
|
535
|
+
},
|
|
536
|
+
hide_index=True,
|
|
537
|
+
use_container_width=False,
|
|
538
|
+
)
|
|
539
|
+
|
|
540
|
+
form = st.form(key='form_hdf5')
|
|
541
|
+
resolution = form.slider('クラスタリング解像度(Leiden resolution)',
|
|
542
|
+
min_value=MIN_CLUSTER_RESOLUTION,
|
|
543
|
+
max_value=MAX_CLUSTER_RESOLUTION,
|
|
544
|
+
value=DEFAULT_CLUSTER_RESOLUTION,
|
|
545
|
+
step=CLUSTER_RESOLUTION_STEP,
|
|
546
|
+
disabled=st.session_state.locked)
|
|
547
|
+
overwrite = form.checkbox('計算済みクラスタ結果を再利用しない(再計算を行う)', value=False, disabled=st.session_state.locked)
|
|
548
|
+
use_umap_embs = form.checkbox('エッジの重み算出にUMAPの埋め込みを使用する', value=False, disabled=st.session_state.locked)
|
|
549
|
+
|
|
550
|
+
cluster_name = ''
|
|
551
|
+
if len(selected_files) > 1:
|
|
552
|
+
cluster_name = form.text_input(
|
|
553
|
+
'クラスタ名('
|
|
554
|
+
'複数スライドで同時処理時は、単一時と区別のための名称が必要です。'
|
|
555
|
+
'サブクラスタークラスター解析時は空欄にしてください)',
|
|
556
|
+
disabled=st.session_state.locked,
|
|
557
|
+
value='', placeholder='半角英数字でクラスタ名を入力してください')
|
|
558
|
+
cluster_name = cluster_name.lower()
|
|
559
|
+
|
|
560
|
+
available_cluster_name = []
|
|
561
|
+
if len(selected_files) == 1:
|
|
562
|
+
# available_cluster_name.append('デフォルト')
|
|
563
|
+
available_cluster_name += list(selected_files[0].detail.cluster_ids_by_name.keys())
|
|
564
|
+
else:
|
|
565
|
+
# ファイルごとのユニークなクラスタ名を取得
|
|
566
|
+
cluster_name_sets = [set(f.detail.cluster_ids_by_name.keys()) for f in selected_files]
|
|
567
|
+
common_cluster_name_set = set.intersection(*cluster_name_sets)
|
|
568
|
+
common_cluster_name_set -= { 'デフォルト' }
|
|
569
|
+
available_cluster_name = list(common_cluster_name_set)
|
|
570
|
+
|
|
571
|
+
subcluster_name = ''
|
|
572
|
+
subcluster_filter = None
|
|
573
|
+
subcluster_label = ''
|
|
574
|
+
if len(available_cluster_name) > 0:
|
|
575
|
+
subcluster_targets_map = { }
|
|
576
|
+
subcluster_targets = []
|
|
577
|
+
for f in selected_files:
|
|
578
|
+
for cluster_name in available_cluster_name:
|
|
579
|
+
cluster_ids = f.detail.cluster_ids_by_name[cluster_name]
|
|
580
|
+
for i in cluster_ids:
|
|
581
|
+
v = f'{cluster_name} - {i}'
|
|
582
|
+
if v not in subcluster_targets:
|
|
583
|
+
subcluster_targets.append(v)
|
|
584
|
+
subcluster_targets_map[v] = [cluster_name, i]
|
|
585
|
+
|
|
586
|
+
subcluster_targets_result = form.multiselect(
|
|
587
|
+
'サブクラスター対象',
|
|
588
|
+
subcluster_targets,
|
|
589
|
+
disabled=st.session_state.locked
|
|
590
|
+
)
|
|
591
|
+
if len(subcluster_targets_result) > 0:
|
|
592
|
+
subcluster_names = []
|
|
593
|
+
subcluster_filter = []
|
|
594
|
+
for r in subcluster_targets_result:
|
|
595
|
+
subcluster_name, id = subcluster_targets_map[r]
|
|
596
|
+
subcluster_names.append(subcluster_name)
|
|
597
|
+
subcluster_filter.append(id)
|
|
598
|
+
if len(set(subcluster_names)) > 1:
|
|
599
|
+
st.error('サブクラスター対象は同一クラスタリング対象から選んでください')
|
|
600
|
+
render_reset_button()
|
|
601
|
+
return
|
|
602
|
+
subcluster_name = subcluster_names[0]
|
|
603
|
+
subcluster_label = 'sub' + '-'.join([str(i) for i in subcluster_filter])
|
|
604
|
+
|
|
605
|
+
if form.form_submit_button('クラスタリングを実行', disabled=st.session_state.locked, on_click=lock):
|
|
606
|
+
set_locked_state(True)
|
|
607
|
+
|
|
608
|
+
if len(selected_files) > 1 and not re.match(r'[a-z0-9]+', cluster_name):
|
|
609
|
+
st.error('クラスタ名は小文字半角英数記号のみ入力してください')
|
|
610
|
+
st.render_reset_button()
|
|
611
|
+
return
|
|
612
|
+
|
|
613
|
+
for f in selected_files:
|
|
614
|
+
if not f.detail or not f.detail.has_features:
|
|
615
|
+
st.write(f'{f.name}の特徴量が未抽出なので、抽出を行います。')
|
|
616
|
+
# Use new command pattern
|
|
617
|
+
commands.set_default_model(st.session_state.model)
|
|
618
|
+
with st.spinner(f'{model_label}特徴量を抽出中...', show_time=True):
|
|
619
|
+
cmd = commands.PatchEmbeddingCommand(batch_size=BATCH_SIZE, overwrite=True)
|
|
620
|
+
result = cmd(f.path)
|
|
621
|
+
st.write(f'{model_label}特徴量の抽出完了。')
|
|
622
|
+
|
|
623
|
+
# Use new command pattern
|
|
624
|
+
commands.set_default_model(st.session_state.model)
|
|
625
|
+
cluster_cmd = commands.ClusteringCommand(
|
|
626
|
+
resolution=resolution,
|
|
627
|
+
cluster_name=cluster_name,
|
|
628
|
+
cluster_filter=subcluster_filter,
|
|
629
|
+
use_umap=use_umap_embs,
|
|
630
|
+
overwrite=overwrite
|
|
631
|
+
)
|
|
632
|
+
|
|
633
|
+
t = 'と'.join([f.name for f in selected_files])
|
|
634
|
+
with st.spinner(f'{t}をクラスタリング中...', show_time=True):
|
|
635
|
+
p = P(selected_files[0].path)
|
|
636
|
+
if len(selected_files) > 1:
|
|
637
|
+
base = cluster_name
|
|
638
|
+
else:
|
|
639
|
+
base = p.stem
|
|
640
|
+
if subcluster_filter:
|
|
641
|
+
base += f'_{subcluster_label}'
|
|
642
|
+
umap_path = str(p.parent / f'{base}_umap.png')
|
|
643
|
+
|
|
644
|
+
result = cluster_cmd([f.path for f in selected_files])
|
|
645
|
+
|
|
646
|
+
# Plot UMAP
|
|
647
|
+
umap_embs = cluster_cmd.get_umap_embeddings()
|
|
648
|
+
fig = plot_umap(umap_embs, cluster_cmd.total_clusters)
|
|
649
|
+
fig.savefig(umap_path, bbox_inches='tight', pad_inches=0.5)
|
|
650
|
+
|
|
651
|
+
st.subheader('UMAP投射 + クラスタリング')
|
|
652
|
+
umap_filename = os.path.basename(umap_path)
|
|
653
|
+
st.image(Image.open(umap_path), caption=umap_filename)
|
|
654
|
+
st.write(f'{umap_filename}に出力しました。')
|
|
655
|
+
|
|
656
|
+
st.divider()
|
|
657
|
+
|
|
658
|
+
with st.spinner('オーバービュー生成中...', show_time=True):
|
|
659
|
+
for f in selected_files:
|
|
660
|
+
# Use new command pattern
|
|
661
|
+
commands.set_default_model(st.session_state.model)
|
|
662
|
+
preview_cmd = commands.PreviewClustersCommand(size=THUMBNAIL_SIZE)
|
|
663
|
+
|
|
664
|
+
p = P(f.path)
|
|
665
|
+
if len(selected_files) > 1:
|
|
666
|
+
base = f'{cluster_name}_{p.stem}'
|
|
667
|
+
else:
|
|
668
|
+
base = p.stem
|
|
669
|
+
if subcluster_filter:
|
|
670
|
+
base += f'_{subcluster_label}'
|
|
671
|
+
thumb_path = str(p.parent / f'{base}_thumb.jpg')
|
|
672
|
+
|
|
673
|
+
if subcluster_filter:
|
|
674
|
+
if subcluster_name == 'デフォルト':
|
|
675
|
+
c = subcluster_label
|
|
676
|
+
else:
|
|
677
|
+
c = f'{cluster_name}_{subcluster_label}'
|
|
678
|
+
else:
|
|
679
|
+
c = cluster_name
|
|
680
|
+
|
|
681
|
+
thumb = preview_cmd(f.path, cluster_name=c)
|
|
682
|
+
thumb.save(thumb_path)
|
|
683
|
+
st.subheader('オーバービュー')
|
|
684
|
+
thumb_filename = os.path.basename(thumb_path)
|
|
685
|
+
st.image(thumb, caption=thumb_filename)
|
|
686
|
+
st.write(f'{thumb_filename}に出力しました。')
|
|
687
|
+
|
|
688
|
+
render_reset_button()
|
|
689
|
+
|
|
690
|
+
|
|
691
|
+
def recognize_file_type(selected_files: List[FileEntry]) -> FileType:
|
|
692
|
+
if len(selected_files) == 0:
|
|
693
|
+
return FileType.EMPTY
|
|
694
|
+
if len(selected_files) == 1:
|
|
695
|
+
f = selected_files[0]
|
|
696
|
+
return f.type
|
|
697
|
+
|
|
698
|
+
type_set = set([f.type for f in selected_files])
|
|
699
|
+
if len(type_set) > 1:
|
|
700
|
+
return FileType.MIX
|
|
701
|
+
t = next(iter(type_set))
|
|
702
|
+
return t
|
|
703
|
+
|
|
704
|
+
def main():
|
|
705
|
+
add_beforeunload_js()
|
|
706
|
+
|
|
707
|
+
if 'locked' not in st.session_state:
|
|
708
|
+
set_locked_state(False)
|
|
709
|
+
|
|
710
|
+
if 'model' not in st.session_state:
|
|
711
|
+
st.session_state.model = DEFAULT_MODEL
|
|
712
|
+
|
|
713
|
+
st.title('ロビえもんNEXT - WSI AI解析システム')
|
|
714
|
+
|
|
715
|
+
if 'current_dir' not in st.session_state:
|
|
716
|
+
st.session_state.current_dir = BASE_DIR
|
|
717
|
+
|
|
718
|
+
default_root_abs = os.path.abspath(BASE_DIR)
|
|
719
|
+
current_dir_abs = os.path.abspath(st.session_state.current_dir)
|
|
720
|
+
|
|
721
|
+
render_navigation(current_dir_abs, default_root_abs)
|
|
722
|
+
|
|
723
|
+
files = list_files(st.session_state.current_dir)
|
|
724
|
+
selected_files = render_file_list(files)
|
|
725
|
+
multi = len(selected_files) > 1
|
|
726
|
+
file_type = recognize_file_type(selected_files)
|
|
727
|
+
|
|
728
|
+
if file_type == FileType.WSI:
|
|
729
|
+
render_mode_wsi(files, selected_files)
|
|
730
|
+
elif file_type == FileType.HDF5:
|
|
731
|
+
render_mode_hdf5(selected_files)
|
|
732
|
+
elif file_type == FileType.IMAGE:
|
|
733
|
+
for f in selected_files:
|
|
734
|
+
img = Image.open(f.path)
|
|
735
|
+
st.image(img)
|
|
736
|
+
elif file_type == FileType.EMPTY:
|
|
737
|
+
st.write('ファイル一覧の左の列のチェックボックスからファイルを選択してください。')
|
|
738
|
+
elif file_type == FileType.DIRECTORY:
|
|
739
|
+
if multi:
|
|
740
|
+
st.warning('複数フォルダが選択されました。')
|
|
741
|
+
else:
|
|
742
|
+
if st.button('このフォルダに移動'):
|
|
743
|
+
st.session_state.current_dir = selected_files[0].path
|
|
744
|
+
st.rerun()
|
|
745
|
+
elif file_type == FileType.OTHER:
|
|
746
|
+
st.warning('WSI(.ndpi, .svs)ファイルもしくはHDF5ファイル(.h5)を選択しください。')
|
|
747
|
+
elif file_type == FileType.MIX:
|
|
748
|
+
st.warning('単一種類のファイルを選択してください。')
|
|
749
|
+
else:
|
|
750
|
+
st.warning(f'Invalid file type: {file_type}')
|
|
751
|
+
|
|
752
|
+
if __name__ == '__main__':
|
|
753
|
+
main()
|