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.
@@ -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
@@ -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()