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/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()