Coverage for openhcs/pyqt_gui/widgets/image_browser.py: 0.0%

1014 statements  

« prev     ^ index     » next       coverage.py v7.11.0, created at 2025-11-04 02:09 +0000

1""" 

2Image Browser Widget for PyQt6 GUI. 

3 

4Displays a table of all image files from plate metadata and allows users to 

5view them in Napari with configurable display settings. 

6""" 

7 

8import logging 

9from pathlib import Path 

10from typing import Optional, List, Dict, Set, Any 

11 

12from PyQt6.QtWidgets import ( 

13 QWidget, QVBoxLayout, QHBoxLayout, QTableWidget, QTableWidgetItem, 

14 QPushButton, QLabel, QHeaderView, QAbstractItemView, QMessageBox, 

15 QSplitter, QGroupBox, QTreeWidget, QTreeWidgetItem, QScrollArea, 

16 QLineEdit, QTabWidget, QTextEdit 

17) 

18from PyQt6.QtCore import Qt, pyqtSignal, pyqtSlot 

19 

20from openhcs.constants.constants import Backend 

21from openhcs.io.filemanager import FileManager 

22from openhcs.io.base import storage_registry 

23from openhcs.pyqt_gui.shared.color_scheme import PyQt6ColorScheme 

24from openhcs.pyqt_gui.shared.style_generator import StyleSheetGenerator 

25from openhcs.pyqt_gui.widgets.shared.column_filter_widget import MultiColumnFilterPanel 

26 

27logger = logging.getLogger(__name__) 

28 

29 

30class ImageBrowserWidget(QWidget): 

31 """ 

32 Image browser widget that displays all image files from plate metadata. 

33  

34 Users can click on files to view them in Napari with configurable settings 

35 from the current PipelineConfig. 

36 """ 

37 

38 # Signals 

39 image_selected = pyqtSignal(str) # Emitted when an image is selected 

40 _status_update_signal = pyqtSignal(str) # Internal signal for thread-safe status updates 

41 

42 def __init__(self, orchestrator=None, color_scheme: Optional[PyQt6ColorScheme] = None, parent=None): 

43 super().__init__(parent) 

44 

45 self.orchestrator = orchestrator 

46 self.color_scheme = color_scheme or PyQt6ColorScheme() 

47 self.style_gen = StyleSheetGenerator(self.color_scheme) 

48 self.filemanager = FileManager(storage_registry) 

49 

50 # Lazy config widgets (will be created in init_ui) 

51 self.napari_config_form = None 

52 self.lazy_napari_config = None 

53 self.fiji_config_form = None 

54 self.lazy_fiji_config = None 

55 

56 # File data tracking (images + results) 

57 self.all_files = {} # filename -> metadata dict (merged images + results) 

58 self.all_images = {} # filename -> metadata dict (images only, temporary for merging) 

59 self.all_results = {} # filename -> file info dict (results only, temporary for merging) 

60 self.result_full_paths = {} # filename -> Path (full path for results, for opening files) 

61 self.filtered_files = {} # filename -> metadata dict (after search/filter) 

62 self.selected_wells = set() # Selected wells for filtering 

63 self.metadata_keys = [] # Column names from parser metadata (union of all keys) 

64 

65 # Plate view widget (will be created in init_ui) 

66 self.plate_view_widget = None 

67 self.plate_view_detached_window = None 

68 self.middle_splitter = None # Reference to splitter for reattaching 

69 

70 # Column filter panel 

71 self.column_filter_panel = None 

72 

73 # Start global ack listener for image acknowledgment tracking 

74 from openhcs.runtime.zmq_base import start_global_ack_listener 

75 start_global_ack_listener() 

76 

77 self.init_ui() 

78 

79 # Connect internal signal for thread-safe status updates 

80 self._status_update_signal.connect(self._update_status_label) 

81 

82 # Load images if orchestrator is provided 

83 if self.orchestrator: 

84 self.load_images() 

85 

86 def init_ui(self): 

87 """Initialize the user interface.""" 

88 layout = QVBoxLayout(self) 

89 layout.setContentsMargins(5, 5, 5, 5) # Reduced margins 

90 layout.setSpacing(5) # Reduced spacing between rows 

91 

92 # Search input row with buttons on the right 

93 search_layout = QHBoxLayout() 

94 search_layout.setSpacing(10) 

95 

96 self.search_input = QLineEdit() 

97 self.search_input.setPlaceholderText("Search images by filename or metadata...") 

98 self.search_input.textChanged.connect(self.filter_images) 

99 # Apply same styling as function selector 

100 self.search_input.setStyleSheet(f""" 

101 QLineEdit {{ 

102 background-color: {self.color_scheme.to_hex(self.color_scheme.input_bg)}; 

103 color: {self.color_scheme.to_hex(self.color_scheme.input_text)}; 

104 border: 1px solid {self.color_scheme.to_hex(self.color_scheme.input_border)}; 

105 border-radius: 3px; 

106 padding: 5px; 

107 }} 

108 QLineEdit:focus {{ 

109 border: 1px solid {self.color_scheme.to_hex(self.color_scheme.input_focus_border)}; 

110 }} 

111 """) 

112 search_layout.addWidget(self.search_input, 1) # Stretch factor 1 - can compress 

113 

114 # Plate view toggle button (moved from bottom) 

115 self.plate_view_toggle_btn = QPushButton("Show Plate View") 

116 self.plate_view_toggle_btn.setCheckable(True) 

117 self.plate_view_toggle_btn.clicked.connect(self._toggle_plate_view) 

118 self.plate_view_toggle_btn.setStyleSheet(self.style_gen.generate_button_style()) 

119 search_layout.addWidget(self.plate_view_toggle_btn, 0) # No stretch 

120 

121 # Refresh button (moved from bottom) 

122 self.refresh_btn = QPushButton("Refresh") 

123 self.refresh_btn.clicked.connect(self.load_images) 

124 self.refresh_btn.setStyleSheet(self.style_gen.generate_button_style()) 

125 search_layout.addWidget(self.refresh_btn, 0) # No stretch 

126 

127 # Info label (moved from bottom) 

128 self.info_label = QLabel("No images loaded") 

129 self.info_label.setStyleSheet(f"color: {self.color_scheme.to_hex(self.color_scheme.text_disabled)};") 

130 search_layout.addWidget(self.info_label, 0) # No stretch 

131 

132 layout.addLayout(search_layout) 

133 

134 # Create main splitter (tree+filters | table | config) 

135 main_splitter = QSplitter(Qt.Orientation.Horizontal) 

136 

137 # Left panel: Vertical splitter for Folder tree + Column filters 

138 left_splitter = QSplitter(Qt.Orientation.Vertical) 

139 

140 # Folder tree 

141 tree_widget = self._create_folder_tree() 

142 left_splitter.addWidget(tree_widget) 

143 

144 # Column filter panel (initially empty, populated when images load) 

145 # DO NOT wrap in scroll area - breaks splitter resizing! 

146 # Each filter has its own scroll area for checkboxes 

147 self.column_filter_panel = MultiColumnFilterPanel(color_scheme=self.color_scheme) 

148 self.column_filter_panel.filters_changed.connect(self._on_column_filters_changed) 

149 self.column_filter_panel.setVisible(False) # Hidden until images load 

150 left_splitter.addWidget(self.column_filter_panel) 

151 

152 # Set initial sizes: filters get more space (20% tree, 80% filters) 

153 left_splitter.setSizes([100, 400]) 

154 

155 main_splitter.addWidget(left_splitter) 

156 

157 # Middle: Vertical splitter for plate view and tabs 

158 self.middle_splitter = QSplitter(Qt.Orientation.Vertical) 

159 

160 # Plate view (initially hidden) 

161 from openhcs.pyqt_gui.widgets.shared.plate_view_widget import PlateViewWidget 

162 self.plate_view_widget = PlateViewWidget(color_scheme=self.color_scheme, parent=self) 

163 self.plate_view_widget.wells_selected.connect(self._on_wells_selected) 

164 self.plate_view_widget.detach_requested.connect(self._detach_plate_view) 

165 self.plate_view_widget.setVisible(False) 

166 self.middle_splitter.addWidget(self.plate_view_widget) 

167 

168 # Single table for both images and results (no tabs needed) 

169 image_table_widget = self._create_table_widget() 

170 self.middle_splitter.addWidget(image_table_widget) 

171 

172 # Set initial sizes (30% plate view, 70% table when visible) 

173 self.middle_splitter.setSizes([150, 350]) 

174 

175 main_splitter.addWidget(self.middle_splitter) 

176 

177 # Right: Napari config panel + instance manager 

178 right_panel = self._create_right_panel() 

179 main_splitter.addWidget(right_panel) 

180 

181 # Set initial splitter sizes (20% tree, 50% middle, 30% config) 

182 main_splitter.setSizes([200, 500, 300]) 

183 

184 # Add splitter with stretch factor to fill vertical space 

185 layout.addWidget(main_splitter, 1) 

186 

187 # Connect selection change 

188 self.file_table.itemSelectionChanged.connect(self.on_selection_changed) 

189 

190 def _create_folder_tree(self): 

191 """Create folder tree widget for filtering images by directory.""" 

192 tree = QTreeWidget() 

193 tree.setHeaderLabel("Folders") 

194 tree.setMinimumWidth(150) 

195 

196 # Apply styling 

197 tree.setStyleSheet(self.style_gen.generate_tree_widget_style()) 

198 

199 # Connect selection to filter table 

200 tree.itemSelectionChanged.connect(self.on_folder_selection_changed) 

201 

202 # Store reference 

203 self.folder_tree = tree 

204 

205 return tree 

206 

207 def _create_table_widget(self): 

208 """Create and configure the unified file table widget (images + results).""" 

209 table_container = QWidget() 

210 layout = QVBoxLayout(table_container) 

211 layout.setContentsMargins(0, 0, 0, 0) 

212 

213 # Unified table for images and results (columns will be set dynamically based on parser metadata) 

214 self.file_table = QTableWidget() 

215 self.file_table.setColumnCount(2) # Start with Filename + Type 

216 self.file_table.setHorizontalHeaderLabels(["Filename", "Type"]) 

217 

218 # Configure table 

219 self.file_table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) 

220 self.file_table.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection) # Enable multi-selection 

221 self.file_table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers) 

222 self.file_table.setSortingEnabled(True) 

223 

224 # Configure header - make all columns resizable and movable (like function selector) 

225 header = self.file_table.horizontalHeader() 

226 header.setSectionsMovable(True) # Allow column reordering 

227 header.setSectionResizeMode(0, QHeaderView.ResizeMode.Interactive) # Filename - resizable 

228 header.setSectionResizeMode(1, QHeaderView.ResizeMode.Interactive) # Type - resizable 

229 

230 # Apply styling 

231 self.file_table.setStyleSheet(self.style_gen.generate_table_widget_style()) 

232 

233 # Connect double-click to view in enabled viewer(s) 

234 self.file_table.cellDoubleClicked.connect(self.on_file_double_clicked) 

235 

236 layout.addWidget(self.file_table) 

237 

238 return table_container 

239 

240 # Removed _create_results_widget - now using unified file table 

241 

242 def _create_right_panel(self): 

243 """Create the right panel with config tabs and instance manager.""" 

244 container = QWidget() 

245 container.setMinimumWidth(300) # Prevent clipping of config widgets 

246 layout = QVBoxLayout(container) 

247 layout.setContentsMargins(0, 0, 0, 0) 

248 layout.setSpacing(5) 

249 

250 # Tab bar row with view buttons 

251 tab_row = QHBoxLayout() 

252 tab_row.setContentsMargins(0, 0, 0, 0) 

253 tab_row.setSpacing(5) 

254 

255 # Tab widget for streaming configs 

256 from PyQt6.QtWidgets import QTabWidget 

257 self.streaming_tabs = QTabWidget() 

258 self.streaming_tabs.setStyleSheet(self.style_gen.generate_tab_widget_style()) 

259 

260 # Napari config panel (with enable checkbox) 

261 napari_panel = self._create_napari_config_panel() 

262 self.napari_tab_index = self.streaming_tabs.addTab(napari_panel, "Napari") 

263 

264 # Fiji config panel (with enable checkbox) 

265 fiji_panel = self._create_fiji_config_panel() 

266 self.fiji_tab_index = self.streaming_tabs.addTab(fiji_panel, "Fiji") 

267 

268 # Update tab text when configs are enabled/disabled 

269 self._update_tab_labels() 

270 

271 # Extract tab bar and add to horizontal layout 

272 self.tab_bar = self.streaming_tabs.tabBar() 

273 self.tab_bar.setExpanding(False) 

274 self.tab_bar.setUsesScrollButtons(False) 

275 tab_row.addWidget(self.tab_bar, 0) # No stretch - tabs at natural size 

276 

277 # View buttons beside tabs 

278 self.view_napari_btn = QPushButton("View in Napari") 

279 self.view_napari_btn.clicked.connect(self.view_selected_in_napari) 

280 self.view_napari_btn.setStyleSheet(self.style_gen.generate_button_style()) 

281 self.view_napari_btn.setEnabled(False) 

282 tab_row.addWidget(self.view_napari_btn, 0) # No stretch 

283 

284 self.view_fiji_btn = QPushButton("View in Fiji") 

285 self.view_fiji_btn.clicked.connect(self.view_selected_in_fiji) 

286 self.view_fiji_btn.setStyleSheet(self.style_gen.generate_button_style()) 

287 self.view_fiji_btn.setEnabled(False) 

288 tab_row.addWidget(self.view_fiji_btn, 0) # No stretch 

289 

290 layout.addLayout(tab_row) 

291 

292 # Vertical splitter for configs and instance manager 

293 vertical_splitter = QSplitter(Qt.Orientation.Vertical) 

294 

295 # Extract the stacked widget (content area) from tab widget and add it to splitter 

296 # The tab bar is already in tab_row above 

297 from PyQt6.QtWidgets import QStackedWidget 

298 stacked_widget = self.streaming_tabs.findChild(QStackedWidget) 

299 if stacked_widget: 

300 stacked_widget.setMinimumWidth(300) # Prevent clipping of config widgets 

301 vertical_splitter.addWidget(stacked_widget) 

302 

303 # Instance manager panel 

304 instance_panel = self._create_instance_manager_panel() 

305 vertical_splitter.addWidget(instance_panel) 

306 

307 # Set initial sizes (80% configs, 20% instance manager) 

308 vertical_splitter.setSizes([400, 100]) 

309 

310 layout.addWidget(vertical_splitter) 

311 

312 return container 

313 

314 def _create_napari_config_panel(self): 

315 """Create the Napari configuration panel with enable checkbox and lazy config widget.""" 

316 from PyQt6.QtWidgets import QCheckBox 

317 

318 panel = QGroupBox() 

319 layout = QVBoxLayout(panel) 

320 layout.setContentsMargins(5, 5, 5, 5) 

321 layout.setSpacing(5) 

322 

323 # Enable checkbox in header 

324 self.napari_enable_checkbox = QCheckBox("Enable Napari Streaming") 

325 self.napari_enable_checkbox.setChecked(True) # Enabled by default 

326 self.napari_enable_checkbox.toggled.connect(self._on_napari_enable_toggled) 

327 layout.addWidget(self.napari_enable_checkbox) 

328 

329 # Create lazy Napari config instance 

330 from openhcs.core.config import LazyNapariStreamingConfig 

331 self.lazy_napari_config = LazyNapariStreamingConfig() 

332 

333 # Create parameter form for the lazy config 

334 from openhcs.pyqt_gui.widgets.shared.parameter_form_manager import ParameterFormManager 

335 

336 # Set up context for placeholder resolution 

337 if self.orchestrator: 

338 context_obj = self.orchestrator.pipeline_config 

339 else: 

340 context_obj = None 

341 

342 self.napari_config_form = ParameterFormManager( 

343 object_instance=self.lazy_napari_config, 

344 field_id="napari_config", 

345 parent=panel, 

346 context_obj=context_obj, 

347 color_scheme=self.color_scheme 

348 ) 

349 

350 # Wrap in scroll area for long forms (vertical scrolling only) 

351 scroll = QScrollArea() 

352 scroll.setWidgetResizable(True) 

353 scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) 

354 scroll.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded) 

355 scroll.setWidget(self.napari_config_form) 

356 layout.addWidget(scroll) 

357 

358 return panel 

359 

360 def _create_fiji_config_panel(self): 

361 """Create the Fiji configuration panel with enable checkbox and lazy config widget.""" 

362 from PyQt6.QtWidgets import QCheckBox 

363 

364 panel = QGroupBox() 

365 layout = QVBoxLayout(panel) 

366 layout.setContentsMargins(5, 5, 5, 5) 

367 layout.setSpacing(5) 

368 

369 # Enable checkbox in header 

370 self.fiji_enable_checkbox = QCheckBox("Enable Fiji Streaming") 

371 self.fiji_enable_checkbox.setChecked(False) # Disabled by default 

372 self.fiji_enable_checkbox.toggled.connect(self._on_fiji_enable_toggled) 

373 layout.addWidget(self.fiji_enable_checkbox) 

374 

375 # Create lazy Fiji config instance 

376 from openhcs.config_framework.lazy_factory import LazyFijiStreamingConfig 

377 self.lazy_fiji_config = LazyFijiStreamingConfig() 

378 

379 # Create parameter form for the lazy config 

380 from openhcs.pyqt_gui.widgets.shared.parameter_form_manager import ParameterFormManager 

381 

382 # Set up context for placeholder resolution 

383 if self.orchestrator: 

384 context_obj = self.orchestrator.pipeline_config 

385 else: 

386 context_obj = None 

387 

388 self.fiji_config_form = ParameterFormManager( 

389 object_instance=self.lazy_fiji_config, 

390 field_id="fiji_config", 

391 parent=panel, 

392 context_obj=context_obj, 

393 color_scheme=self.color_scheme 

394 ) 

395 

396 # Wrap in scroll area for long forms (vertical scrolling only) 

397 scroll = QScrollArea() 

398 scroll.setWidgetResizable(True) 

399 scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) 

400 scroll.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded) 

401 scroll.setWidget(self.fiji_config_form) 

402 layout.addWidget(scroll) 

403 

404 # Initially disable the form (checkbox is unchecked by default) 

405 self.fiji_config_form.setEnabled(False) 

406 

407 return panel 

408 

409 def _update_tab_labels(self): 

410 """Update tab labels to show enabled/disabled status.""" 

411 napari_enabled = self.napari_enable_checkbox.isChecked() 

412 fiji_enabled = self.fiji_enable_checkbox.isChecked() 

413 

414 napari_label = "Napari ✓" if napari_enabled else "Napari" 

415 fiji_label = "Fiji ✓" if fiji_enabled else "Fiji" 

416 

417 self.streaming_tabs.setTabText(self.napari_tab_index, napari_label) 

418 self.streaming_tabs.setTabText(self.fiji_tab_index, fiji_label) 

419 

420 def _on_napari_enable_toggled(self, checked: bool): 

421 """Handle Napari enable checkbox toggle.""" 

422 self.napari_config_form.setEnabled(checked) 

423 self.view_napari_btn.setEnabled(checked and len(self.file_table.selectedItems()) > 0) 

424 self._update_tab_labels() 

425 

426 def _on_fiji_enable_toggled(self, checked: bool): 

427 """Handle Fiji enable checkbox toggle.""" 

428 self.fiji_config_form.setEnabled(checked) 

429 self.view_fiji_btn.setEnabled(checked and len(self.file_table.selectedItems()) > 0) 

430 self._update_tab_labels() 

431 

432 def _create_instance_manager_panel(self): 

433 """Create the viewer instance manager panel using ZMQServerManagerWidget.""" 

434 from openhcs.pyqt_gui.widgets.shared.zmq_server_manager import ZMQServerManagerWidget 

435 from openhcs.core.config import get_all_streaming_ports 

436 

437 # Scan all streaming ports using orchestrator's pipeline config 

438 # This ensures we find viewers launched with custom ports 

439 # Exclude execution server port (only want viewer ports) 

440 from openhcs.constants.constants import DEFAULT_EXECUTION_SERVER_PORT 

441 all_ports = get_all_streaming_ports( 

442 config=self.orchestrator.pipeline_config if self.orchestrator else None, 

443 num_ports_per_type=10 

444 ) 

445 ports_to_scan = [p for p in all_ports if p != DEFAULT_EXECUTION_SERVER_PORT] 

446 

447 # Create ZMQ server manager widget 

448 zmq_manager = ZMQServerManagerWidget( 

449 ports_to_scan=ports_to_scan, 

450 title="Viewer Instances", 

451 style_generator=self.style_gen, 

452 parent=self 

453 ) 

454 

455 return zmq_manager 

456 

457 def set_orchestrator(self, orchestrator): 

458 """Set the orchestrator and load images.""" 

459 self.orchestrator = orchestrator 

460 

461 # Use orchestrator's FileManager (has plate-specific backends like VirtualWorkspaceBackend) 

462 if orchestrator: 

463 self.filemanager = orchestrator.filemanager 

464 logger.debug("Image browser now using orchestrator's FileManager") 

465 

466 # Update config form contexts to use new pipeline_config 

467 if self.napari_config_form and orchestrator: 

468 self.napari_config_form.context_obj = orchestrator.pipeline_config 

469 # Refresh placeholders with new context (uses private method) 

470 self.napari_config_form._refresh_all_placeholders() 

471 

472 if self.fiji_config_form and orchestrator: 

473 self.fiji_config_form.context_obj = orchestrator.pipeline_config 

474 self.fiji_config_form._refresh_all_placeholders() 

475 

476 self.load_images() 

477 

478 def _restore_folder_selection(self, folder_path: str, folder_items: Dict): 

479 """Restore folder selection after tree rebuild.""" 

480 if folder_path in folder_items: 

481 item = folder_items[folder_path] 

482 item.setSelected(True) 

483 # Expand parents to make selection visible 

484 parent = item.parent() 

485 while parent: 

486 parent.setExpanded(True) 

487 parent = parent.parent() 

488 

489 def on_folder_selection_changed(self): 

490 """Handle folder tree selection changes to filter table.""" 

491 # Apply folder filter on top of search filter 

492 self._apply_combined_filters() 

493 

494 # Update plate view for new folder 

495 if self.plate_view_widget and self.plate_view_widget.isVisible(): 

496 self._update_plate_view() 

497 

498 def _apply_combined_filters(self): 

499 """Apply search, folder, well, and column filters together.""" 

500 # Start with search-filtered files 

501 result = self.filtered_files.copy() 

502 

503 # Apply folder filter if a folder is selected 

504 selected_items = self.folder_tree.selectedItems() 

505 if selected_items: 

506 folder_path = selected_items[0].data(0, Qt.ItemDataRole.UserRole) 

507 if folder_path: # Not root 

508 # Filter by folder - include both the selected folder AND its associated results folder 

509 # e.g., if "images" is selected, also include "images_results" 

510 results_folder_path = f"{folder_path}_results" 

511 

512 result = { 

513 filename: metadata for filename, metadata in result.items() 

514 if (str(Path(filename).parent) == folder_path or 

515 filename.startswith(folder_path + "/") or 

516 str(Path(filename).parent) == results_folder_path or 

517 filename.startswith(results_folder_path + "/")) 

518 } 

519 

520 # Apply well filter if wells are selected 

521 if self.selected_wells: 

522 result = { 

523 filename: metadata for filename, metadata in result.items() 

524 if self._matches_wells(filename, metadata) 

525 } 

526 

527 # Apply column filters 

528 if self.column_filter_panel: 

529 active_filters = self.column_filter_panel.get_active_filters() 

530 if active_filters: 

531 # Filter with AND logic across columns 

532 filtered_result = {} 

533 for filename, metadata in result.items(): 

534 matches = True 

535 for column_name, selected_values in active_filters.items(): 

536 # Get the metadata key (lowercase with underscores) 

537 metadata_key = column_name.lower().replace(' ', '_') 

538 raw_value = metadata.get(metadata_key, '') 

539 # Get display value for comparison (metadata name if available) 

540 item_value = self._get_metadata_display_value(metadata_key, raw_value) 

541 if item_value not in selected_values: 

542 matches = False 

543 break 

544 if matches: 

545 filtered_result[filename] = metadata 

546 result = filtered_result 

547 

548 # Update table with combined filters 

549 self._populate_table(result) 

550 logger.debug(f"Combined filters: {len(result)} images shown") 

551 

552 def _get_metadata_display_value(self, metadata_key: str, raw_value: Any) -> str: 

553 """ 

554 Get display value for metadata, using metadata cache if available. 

555 

556 For components like channel, this returns "1 | W1" format (raw key | metadata name) 

557 to preserve both the number and the metadata name. This handles cases where 

558 different subdirectories might have the same channel number mapped to different names. 

559 

560 Args: 

561 metadata_key: Metadata key (e.g., "channel", "site", "well") 

562 raw_value: Raw value from parser (e.g., 1, 2, "A01") 

563 

564 Returns: 

565 Display value in format "raw_key | metadata_name" if metadata available, 

566 otherwise just "raw_key" 

567 """ 

568 if raw_value is None: 

569 return 'N/A' 

570 

571 # Convert to string for lookup 

572 value_str = str(raw_value) 

573 

574 # Try to get metadata display name from cache 

575 if self.orchestrator: 

576 try: 

577 # Map metadata_key to AllComponents enum 

578 from openhcs.constants import AllComponents 

579 component_map = { 

580 'channel': AllComponents.CHANNEL, 

581 'site': AllComponents.SITE, 

582 'z_index': AllComponents.Z_INDEX, 

583 'timepoint': AllComponents.TIMEPOINT, 

584 'well': AllComponents.WELL, 

585 } 

586 

587 component = component_map.get(metadata_key) 

588 if component: 

589 metadata_name = self.orchestrator._metadata_cache_service.get_component_metadata(component, value_str) 

590 if metadata_name: 

591 # Format like TUI: "Channel 1 | HOECHST 33342" 

592 # But for table cells, just show "1 | W1" (more compact) 

593 return f"{value_str} | {metadata_name}" 

594 except Exception as e: 

595 logger.debug(f"Could not get metadata for {metadata_key} {value_str}: {e}") 

596 

597 # Fallback to raw value only 

598 return value_str 

599 

600 def _build_column_filters(self): 

601 """Build column filter widgets from loaded file metadata.""" 

602 if not self.all_files or not self.metadata_keys: 

603 return 

604 

605 # Clear existing filters 

606 self.column_filter_panel.clear_all_filters() 

607 

608 # Extract unique values for each metadata column 

609 for metadata_key in self.metadata_keys: 

610 unique_values = set() 

611 for metadata in self.all_files.values(): 

612 value = metadata.get(metadata_key) 

613 if value is not None: 

614 # Use metadata display value instead of raw value 

615 display_value = self._get_metadata_display_value(metadata_key, value) 

616 unique_values.add(display_value) 

617 

618 if unique_values: 

619 # Create filter for this column 

620 column_display_name = metadata_key.replace('_', ' ').title() 

621 self.column_filter_panel.add_column_filter(column_display_name, sorted(list(unique_values))) 

622 

623 # Show filter panel if we have filters 

624 if self.column_filter_panel.column_filters: 

625 self.column_filter_panel.setVisible(True) 

626 

627 # Connect well filter to plate view for bidirectional sync 

628 if 'Well' in self.column_filter_panel.column_filters and self.plate_view_widget: 

629 well_filter = self.column_filter_panel.column_filters['Well'] 

630 self.plate_view_widget.set_well_filter_widget(well_filter) 

631 

632 # Connect well filter changes to sync back to plate view 

633 well_filter.filter_changed.connect(self._on_well_filter_changed) 

634 

635 logger.debug(f"Built {len(self.column_filter_panel.column_filters)} column filters") 

636 

637 def _on_column_filters_changed(self): 

638 """Handle column filter changes.""" 

639 self._apply_combined_filters() 

640 

641 def _on_well_filter_changed(self): 

642 """Handle well filter checkbox changes - sync to plate view.""" 

643 if self.plate_view_widget: 

644 self.plate_view_widget.sync_from_well_filter() 

645 # Apply the filter to the table 

646 self._apply_combined_filters() 

647 

648 def filter_images(self, search_term: str): 

649 """Filter files using shared search service (canonical code path).""" 

650 from openhcs.ui.shared.search_service import SearchService 

651 

652 # Create searchable text extractor 

653 def create_searchable_text(metadata): 

654 """Create searchable text from file metadata.""" 

655 searchable_fields = [metadata.get('filename', '')] 

656 # Add all metadata values 

657 for key, value in metadata.items(): 

658 if key != 'filename' and value is not None: 

659 searchable_fields.append(str(value)) 

660 return " ".join(str(field) for field in searchable_fields) 

661 

662 # Create search service if not exists 

663 if not hasattr(self, '_search_service'): 

664 self._search_service = SearchService( 

665 all_items=self.all_files, 

666 searchable_text_extractor=create_searchable_text 

667 ) 

668 

669 # Update search service with current files 

670 self._search_service.update_items(self.all_files) 

671 

672 # Perform search using shared service 

673 self.filtered_files = self._search_service.filter(search_term) 

674 

675 # Apply combined filters (search + folder + column filters) 

676 self._apply_combined_filters() 

677 

678 def load_images(self): 

679 """Load image files from the orchestrator's metadata.""" 

680 if not self.orchestrator: 

681 self.info_label.setText("No plate loaded") 

682 # Still try to load results even if no orchestrator 

683 self.load_results() 

684 return 

685 

686 try: 

687 logger.info("IMAGE BROWSER: Starting load_images()") 

688 # Get metadata handler from orchestrator 

689 handler = self.orchestrator.microscope_handler 

690 metadata_handler = handler.metadata_handler 

691 logger.info(f"IMAGE BROWSER: Got metadata handler: {type(metadata_handler).__name__}") 

692 

693 # Get image files from metadata 

694 plate_path = self.orchestrator.plate_path 

695 logger.info(f"IMAGE BROWSER: Calling get_image_files for plate: {plate_path}") 

696 image_files = metadata_handler.get_image_files(plate_path) 

697 logger.info(f"IMAGE BROWSER: get_image_files returned {len(image_files) if image_files else 0} files") 

698 

699 if not image_files: 

700 self.info_label.setText("No images found") 

701 # Still load results even if no images 

702 self.load_results() 

703 return 

704 

705 # Build all_images dictionary 

706 self.all_images = {} 

707 for filename in image_files: 

708 parsed = handler.parser.parse_filename(filename) 

709 metadata = {'filename': filename} 

710 if parsed: 

711 metadata.update(parsed) 

712 self.all_images[filename] = metadata 

713 

714 logger.info(f"IMAGE BROWSER: Built all_images dict with {len(self.all_images)} entries") 

715 

716 except Exception as e: 

717 logger.error(f"Failed to load images: {e}", exc_info=True) 

718 QMessageBox.warning(self, "Error", f"Failed to load images: {e}") 

719 self.info_label.setText("Error loading images") 

720 self.all_images = {} 

721 

722 # Load results and merge with images 

723 self.load_results() 

724 

725 # Merge images and results into unified all_files dictionary 

726 self.all_files = {**self.all_images, **self.all_results} 

727 

728 # Determine metadata keys from all files (union of all keys) 

729 all_keys = set() 

730 for file_metadata in self.all_files.values(): 

731 all_keys.update(file_metadata.keys()) 

732 

733 # Remove 'filename' from keys (it's always the first column) 

734 all_keys.discard('filename') 

735 

736 # Sort keys for consistent column order (extension first, then alphabetical) 

737 self.metadata_keys = sorted(all_keys, key=lambda k: (k != 'extension', k)) 

738 

739 # Set up table columns: Filename + metadata keys 

740 column_headers = ["Filename"] + [k.replace('_', ' ').title() for k in self.metadata_keys] 

741 self.file_table.setColumnCount(len(column_headers)) 

742 self.file_table.setHorizontalHeaderLabels(column_headers) 

743 

744 # Set all columns to Interactive resize mode 

745 header = self.file_table.horizontalHeader() 

746 for col in range(len(column_headers)): 

747 header.setSectionResizeMode(col, QHeaderView.ResizeMode.Interactive) 

748 

749 # Initialize filtered files to all files 

750 self.filtered_files = self.all_files.copy() 

751 

752 # Build folder tree from file paths 

753 self._build_folder_tree() 

754 

755 # Build column filters from metadata 

756 self._build_column_filters() 

757 

758 # Populate table 

759 self._populate_table(self.filtered_files) 

760 

761 # Update info label 

762 total_files = len(self.all_files) 

763 num_images = len(self.all_images) 

764 num_results = len(self.all_results) 

765 self.info_label.setText(f"{total_files} files loaded ({num_images} images, {num_results} results)") 

766 

767 # Update plate view if visible 

768 if self.plate_view_widget and self.plate_view_widget.isVisible(): 

769 self._update_plate_view() 

770 

771 def load_results(self): 

772 """Load result files (ROI JSON, CSV) from the results directory and populate self.all_results.""" 

773 self.all_results = {} 

774 

775 if not self.orchestrator: 

776 logger.warning("IMAGE BROWSER RESULTS: No orchestrator available") 

777 return 

778 

779 try: 

780 # Get results directory from metadata (single source of truth) 

781 # The metadata contains the results_dir field that was calculated during compilation 

782 handler = self.orchestrator.microscope_handler 

783 plate_path = self.orchestrator.plate_path 

784 

785 # Load metadata JSON directly 

786 from openhcs.io.metadata_writer import get_metadata_path 

787 import json 

788 

789 metadata_path = get_metadata_path(plate_path) 

790 if not metadata_path.exists(): 

791 logger.warning(f"IMAGE BROWSER RESULTS: Metadata file not found: {metadata_path}") 

792 self.all_results = {} 

793 return 

794 

795 with open(metadata_path) as f: 

796 metadata = json.load(f) 

797 

798 # Find the main subdirectory's results_dir field 

799 results_dir = None 

800 if metadata and 'subdirectories' in metadata: 

801 # First try to find the subdirectory marked as main 

802 for subdir_name, subdir_metadata in metadata['subdirectories'].items(): 

803 if subdir_metadata.get('main') and 'results_dir' in subdir_metadata and subdir_metadata['results_dir']: 

804 # Build full path: plate_path / results_dir 

805 results_dir = plate_path / subdir_metadata['results_dir'] 

806 logger.info(f"IMAGE BROWSER RESULTS: Found results_dir in main subdirectory '{subdir_name}': {subdir_metadata['results_dir']}") 

807 break 

808 

809 # Fallback: if no main subdirectory, use first subdirectory with results_dir 

810 if not results_dir: 

811 for subdir_name, subdir_metadata in metadata['subdirectories'].items(): 

812 if 'results_dir' in subdir_metadata and subdir_metadata['results_dir']: 

813 results_dir = plate_path / subdir_metadata['results_dir'] 

814 logger.info(f"IMAGE BROWSER RESULTS: Found results_dir in subdirectory '{subdir_name}': {subdir_metadata['results_dir']}") 

815 break 

816 

817 if not results_dir: 

818 logger.warning("IMAGE BROWSER RESULTS: No results_dir found in metadata") 

819 return 

820 

821 logger.info(f"IMAGE BROWSER RESULTS: plate_path = {plate_path}") 

822 logger.info(f"IMAGE BROWSER RESULTS: Resolved results directory = {results_dir}") 

823 

824 if not results_dir.exists(): 

825 logger.warning(f"IMAGE BROWSER RESULTS: Results directory does not exist: {results_dir}") 

826 return 

827 

828 # Get parser from orchestrator for filename parsing 

829 handler = self.orchestrator.microscope_handler 

830 

831 # Scan for ROI JSON files and CSV files 

832 logger.info(f"IMAGE BROWSER RESULTS: Scanning directory recursively...") 

833 file_count = 0 

834 for file_path in results_dir.rglob('*'): 

835 if file_path.is_file(): 

836 file_count += 1 

837 suffix = file_path.suffix.lower() 

838 logger.debug(f"IMAGE BROWSER RESULTS: Found file: {file_path.name} (suffix={suffix})") 

839 

840 # Determine file type using FileFormat registry 

841 from openhcs.constants.constants import FileFormat 

842 

843 file_type = None 

844 if file_path.name.endswith('.roi.zip'): 

845 file_type = 'ROI' 

846 logger.info(f"IMAGE BROWSER RESULTS: ✓ Matched as ROI: {file_path.name}") 

847 elif suffix in FileFormat.CSV.value: 

848 file_type = 'CSV' 

849 logger.info(f"IMAGE BROWSER RESULTS: ✓ Matched as CSV: {file_path.name}") 

850 elif suffix in FileFormat.JSON.value: 

851 file_type = 'JSON' 

852 logger.info(f"IMAGE BROWSER RESULTS: ✓ Matched as JSON: {file_path.name}") 

853 else: 

854 logger.debug(f"IMAGE BROWSER RESULTS: ✗ Filtered out: {file_path.name} (suffix={suffix})") 

855 

856 if file_type: 

857 # Get relative path from plate_path (not results_dir) to include subdirectory 

858 rel_path = file_path.relative_to(plate_path) 

859 

860 # Get file size 

861 size_bytes = file_path.stat().st_size 

862 if size_bytes < 1024: 

863 size_str = f"{size_bytes} B" 

864 elif size_bytes < 1024 * 1024: 

865 size_str = f"{size_bytes / 1024:.1f} KB" 

866 else: 

867 size_str = f"{size_bytes / (1024 * 1024):.1f} MB" 

868 

869 # Parse ONLY the filename (not the full path) to extract metadata 

870 parsed = handler.parser.parse_filename(file_path.name) 

871 

872 # Build file info with parsed metadata (no full_path in metadata dict) 

873 file_info = { 

874 'filename': str(rel_path), 

875 'type': file_type, 

876 'size': size_str, 

877 } 

878 

879 # Add parsed metadata components if parsing succeeded 

880 if parsed: 

881 file_info.update(parsed) 

882 logger.info(f"IMAGE BROWSER RESULTS: ✓ Parsed result: {file_path.name} -> {parsed}") 

883 logger.info(f"IMAGE BROWSER RESULTS: Full file_info: {file_info}") 

884 else: 

885 logger.warning(f"IMAGE BROWSER RESULTS: ✗ Could not parse filename: {file_path.name}") 

886 

887 # Store file info and full path separately 

888 self.all_results[str(rel_path)] = file_info 

889 self.result_full_paths[str(rel_path)] = file_path 

890 

891 logger.info(f"IMAGE BROWSER RESULTS: Scanned {file_count} total files, matched {len(self.all_results)} result files") 

892 

893 except Exception as e: 

894 logger.error(f"IMAGE BROWSER RESULTS: Failed to load results: {e}", exc_info=True) 

895 

896 # Removed _populate_results_table - now using unified _populate_table 

897 # Removed on_result_double_clicked - now using unified on_file_double_clicked 

898 

899 def _stream_roi_file(self, roi_zip_path: Path): 

900 """Load ROI .roi.zip file and stream to enabled viewer(s).""" 

901 try: 

902 from openhcs.core.roi import load_rois_from_zip 

903 

904 # Load ROIs from .roi.zip archive 

905 rois = load_rois_from_zip(roi_zip_path) 

906 

907 if not rois: 

908 QMessageBox.information(self, "No ROIs", f"No ROIs found in {roi_zip_path.name}") 

909 return 

910 

911 # Check which viewers are enabled 

912 napari_enabled = self.napari_enable_checkbox.isChecked() 

913 fiji_enabled = self.fiji_enable_checkbox.isChecked() 

914 

915 if not napari_enabled and not fiji_enabled: 

916 QMessageBox.information( 

917 self, 

918 "No Viewer Enabled", 

919 "Please enable Napari or Fiji streaming to view ROIs." 

920 ) 

921 return 

922 

923 # Stream to enabled viewers 

924 if napari_enabled: 

925 self._stream_rois_to_napari(rois, roi_zip_path) 

926 

927 if fiji_enabled: 

928 self._stream_rois_to_fiji(rois, roi_zip_path) 

929 

930 logger.info(f"Streamed {len(rois)} ROIs from {roi_zip_path.name}") 

931 

932 except Exception as e: 

933 logger.error(f"Failed to stream ROI file: {e}") 

934 QMessageBox.warning(self, "Error", f"Failed to stream ROI file: {e}") 

935 

936 def _populate_table(self, files_dict: Dict[str, Dict]): 

937 """Populate table with files (images + results) from dictionary.""" 

938 # Clear table 

939 self.file_table.setRowCount(0) 

940 

941 # Populate rows 

942 for i, (filename, metadata) in enumerate(files_dict.items()): 

943 self.file_table.insertRow(i) 

944 

945 # Filename column 

946 filename_item = QTableWidgetItem(filename) 

947 filename_item.setData(Qt.ItemDataRole.UserRole, filename) 

948 self.file_table.setItem(i, 0, filename_item) 

949 

950 # Metadata columns - use metadata display values 

951 for col_idx, key in enumerate(self.metadata_keys, start=1): 

952 value = metadata.get(key, 'N/A') 

953 # Get display value (metadata name if available, otherwise raw value) 

954 display_value = self._get_metadata_display_value(key, value) 

955 self.file_table.setItem(i, col_idx, QTableWidgetItem(display_value)) 

956 

957 def _build_folder_tree(self): 

958 """Build folder tree from file paths (images + results).""" 

959 # Save current selection before rebuilding 

960 selected_folder = None 

961 selected_items = self.folder_tree.selectedItems() 

962 if selected_items: 

963 selected_folder = selected_items[0].data(0, Qt.ItemDataRole.UserRole) 

964 

965 self.folder_tree.clear() 

966 

967 # Extract unique folder paths (exclude *_results folders since they're auto-included) 

968 folders: Set[str] = set() 

969 for filename in self.all_files.keys(): 

970 path = Path(filename) 

971 # Add all parent directories 

972 for parent in path.parents: 

973 parent_str = str(parent) 

974 if parent_str != '.' and not parent_str.endswith('_results'): 

975 folders.add(parent_str) 

976 

977 # Build tree structure 

978 root_item = QTreeWidgetItem(["All Files"]) 

979 root_item.setData(0, Qt.ItemDataRole.UserRole, None) 

980 self.folder_tree.addTopLevelItem(root_item) 

981 

982 # Sort folders for consistent display 

983 sorted_folders = sorted(folders) 

984 

985 # Create tree items for each folder 

986 folder_items = {} 

987 for folder in sorted_folders: 

988 parts = Path(folder).parts 

989 if len(parts) == 1: 

990 # Top-level folder 

991 item = QTreeWidgetItem([folder]) 

992 item.setData(0, Qt.ItemDataRole.UserRole, folder) 

993 root_item.addChild(item) 

994 folder_items[folder] = item 

995 else: 

996 # Nested folder - find parent 

997 parent_path = str(Path(folder).parent) 

998 if parent_path in folder_items: 

999 item = QTreeWidgetItem([Path(folder).name]) 

1000 item.setData(0, Qt.ItemDataRole.UserRole, folder) 

1001 folder_items[parent_path].addChild(item) 

1002 folder_items[folder] = item 

1003 

1004 # Start with everything collapsed (user can expand to explore) 

1005 root_item.setExpanded(False) 

1006 

1007 # Restore previous selection if it still exists 

1008 if selected_folder is not None: 

1009 self._restore_folder_selection(selected_folder, folder_items) 

1010 

1011 def on_selection_changed(self): 

1012 """Handle selection change in the table.""" 

1013 has_selection = len(self.file_table.selectedItems()) > 0 

1014 # Enable buttons based on selection AND checkbox state 

1015 self.view_napari_btn.setEnabled(has_selection and self.napari_enable_checkbox.isChecked()) 

1016 self.view_fiji_btn.setEnabled(has_selection and self.fiji_enable_checkbox.isChecked()) 

1017 

1018 def on_file_double_clicked(self, row: int, column: int): 

1019 """Handle double-click on a file row - stream images or display results.""" 

1020 # Get file info from table 

1021 filename_item = self.file_table.item(row, 0) 

1022 filename = filename_item.data(Qt.ItemDataRole.UserRole) 

1023 

1024 # Check if this is a result file (has 'type' field) or an image 

1025 file_info = self.all_files.get(filename, {}) 

1026 file_type = file_info.get('type') 

1027 

1028 if file_type: 

1029 # This is a result file (ROI, CSV, JSON) 

1030 self._handle_result_double_click(file_info) 

1031 else: 

1032 # This is an image file 

1033 self._handle_image_double_click() 

1034 

1035 def _handle_image_double_click(self): 

1036 """Handle double-click on an image - stream to enabled viewer(s).""" 

1037 napari_enabled = self.napari_enable_checkbox.isChecked() 

1038 fiji_enabled = self.fiji_enable_checkbox.isChecked() 

1039 

1040 # Stream to whichever viewer(s) are enabled 

1041 if napari_enabled and fiji_enabled: 

1042 # Both enabled - stream to both 

1043 self.view_selected_in_napari() 

1044 self.view_selected_in_fiji() 

1045 elif napari_enabled: 

1046 # Only Napari enabled 

1047 self.view_selected_in_napari() 

1048 elif fiji_enabled: 

1049 # Only Fiji enabled 

1050 self.view_selected_in_fiji() 

1051 else: 

1052 # Neither enabled - show message 

1053 QMessageBox.information( 

1054 self, 

1055 "No Viewer Enabled", 

1056 "Please enable Napari or Fiji streaming to view images." 

1057 ) 

1058 

1059 def _handle_result_double_click(self, file_info: dict): 

1060 """Handle double-click on a result file - stream ROIs or display CSV.""" 

1061 filename = file_info['filename'] 

1062 file_path = self.result_full_paths.get(filename) 

1063 

1064 if not file_path: 

1065 logger.error(f"Could not find full path for result file: {filename}") 

1066 return 

1067 

1068 file_type = file_info['type'] 

1069 

1070 if file_type == 'ROI': 

1071 # Stream ROI JSON to enabled viewer(s) 

1072 self._stream_roi_file(file_path) 

1073 elif file_type == 'CSV': 

1074 # Open CSV in system default application 

1075 import subprocess 

1076 subprocess.run(['xdg-open', str(file_path)]) 

1077 elif file_type == 'JSON': 

1078 # Open JSON in system default application 

1079 import subprocess 

1080 subprocess.run(['xdg-open', str(file_path)]) 

1081 

1082 def view_selected_in_napari(self): 

1083 """View all selected images in Napari as a batch (builds hyperstack).""" 

1084 selected_rows = self.file_table.selectionModel().selectedRows() 

1085 if not selected_rows: 

1086 return 

1087 

1088 # Collect all filenames and separate ROI files from images 

1089 image_filenames = [] 

1090 roi_filenames = [] 

1091 for row_index in selected_rows: 

1092 row = row_index.row() 

1093 filename_item = self.file_table.item(row, 0) 

1094 filename = filename_item.data(Qt.ItemDataRole.UserRole) 

1095 

1096 # Check if this is a .roi.zip file 

1097 if filename.endswith('.roi.zip'): 

1098 roi_filenames.append(filename) 

1099 else: 

1100 image_filenames.append(filename) 

1101 

1102 try: 

1103 # Stream ROI files separately 

1104 if roi_filenames: 

1105 plate_path = Path(self.orchestrator.plate_path) 

1106 for roi_filename in roi_filenames: 

1107 roi_path = plate_path / roi_filename 

1108 self._stream_roi_file(roi_path) 

1109 

1110 # Stream image files as a batch 

1111 if image_filenames: 

1112 self._load_and_stream_batch_to_napari(image_filenames) 

1113 

1114 except Exception as e: 

1115 logger.error(f"Failed to view images in Napari: {e}") 

1116 QMessageBox.warning(self, "Error", f"Failed to view images in Napari: {e}") 

1117 

1118 def view_selected_in_fiji(self): 

1119 """View all selected images in Fiji as a batch (builds hyperstack).""" 

1120 selected_rows = self.file_table.selectionModel().selectedRows() 

1121 if not selected_rows: 

1122 return 

1123 

1124 # Collect all filenames and separate ROI files from images 

1125 image_filenames = [] 

1126 roi_filenames = [] 

1127 for row_index in selected_rows: 

1128 row = row_index.row() 

1129 filename_item = self.file_table.item(row, 0) 

1130 filename = filename_item.data(Qt.ItemDataRole.UserRole) 

1131 

1132 # Check if this is a .roi.zip file 

1133 if filename.endswith('.roi.zip'): 

1134 roi_filenames.append(filename) 

1135 else: 

1136 image_filenames.append(filename) 

1137 

1138 logger.info(f"🎯 IMAGE BROWSER: User selected {len(image_filenames)} images and {len(roi_filenames)} ROI files to view in Fiji") 

1139 logger.info(f"🎯 IMAGE BROWSER: Image filenames: {image_filenames[:5]}{'...' if len(image_filenames) > 5 else ''}") 

1140 if roi_filenames: 

1141 logger.info(f"🎯 IMAGE BROWSER: ROI filenames: {roi_filenames}") 

1142 

1143 try: 

1144 # Stream ROI files separately 

1145 if roi_filenames: 

1146 plate_path = Path(self.orchestrator.plate_path) 

1147 for roi_filename in roi_filenames: 

1148 roi_path = plate_path / roi_filename 

1149 self._stream_roi_file(roi_path) 

1150 

1151 # Stream image files as a batch 

1152 if image_filenames: 

1153 self._load_and_stream_batch_to_fiji(image_filenames) 

1154 

1155 except Exception as e: 

1156 logger.error(f"Failed to view images in Fiji: {e}") 

1157 QMessageBox.warning(self, "Error", f"Failed to view images in Fiji: {e}") 

1158 

1159 def _load_and_stream_batch_to_napari(self, filenames: list): 

1160 """Load multiple images and stream as batch to Napari (builds hyperstack).""" 

1161 if not self.orchestrator: 

1162 raise RuntimeError("No orchestrator set") 

1163 

1164 # Get plate path 

1165 plate_path = Path(self.orchestrator.plate_path) 

1166 

1167 # Resolve backend (lightweight operation, safe in UI thread) 

1168 from openhcs.config_framework.global_config import get_current_global_config 

1169 from openhcs.core.config import GlobalPipelineConfig 

1170 global_config = get_current_global_config(GlobalPipelineConfig) 

1171 

1172 if global_config.vfs_config.read_backend != Backend.AUTO: 

1173 read_backend = global_config.vfs_config.read_backend.value 

1174 else: 

1175 read_backend = self.orchestrator.microscope_handler.get_primary_backend(plate_path, self.orchestrator.filemanager) 

1176 

1177 # Resolve Napari config (lightweight operation, safe in UI thread) 

1178 from openhcs.config_framework.context_manager import config_context 

1179 from openhcs.config_framework.lazy_factory import resolve_lazy_configurations_for_serialization, LazyNapariStreamingConfig 

1180 

1181 current_values = self.napari_config_form.get_current_values() 

1182 temp_config = LazyNapariStreamingConfig(**{k: v for k, v in current_values.items() if v is not None}) 

1183 

1184 with config_context(self.orchestrator.pipeline_config): 

1185 with config_context(temp_config): 

1186 napari_config = resolve_lazy_configurations_for_serialization(temp_config) 

1187 

1188 # Get or create viewer (lightweight operation, safe in UI thread) 

1189 viewer = self.orchestrator.get_or_create_visualizer(napari_config) 

1190 

1191 # Load and stream in background thread (HEAVY OPERATION - must not block UI) 

1192 self._load_and_stream_batch_to_napari_async( 

1193 viewer, filenames, plate_path, read_backend, napari_config 

1194 ) 

1195 

1196 logger.info(f"Loading and streaming batch of {len(filenames)} images to Napari viewer on port {napari_config.port}...") 

1197 

1198 def _load_and_stream_batch_to_fiji(self, filenames: list): 

1199 """Load multiple images and stream as batch to Fiji (builds hyperstack).""" 

1200 if not self.orchestrator: 

1201 raise RuntimeError("No orchestrator set") 

1202 

1203 # Get plate path 

1204 plate_path = Path(self.orchestrator.plate_path) 

1205 

1206 # Resolve backend (lightweight operation, safe in UI thread) 

1207 from openhcs.config_framework.global_config import get_current_global_config 

1208 from openhcs.core.config import GlobalPipelineConfig 

1209 global_config = get_current_global_config(GlobalPipelineConfig) 

1210 

1211 if global_config.vfs_config.read_backend != Backend.AUTO: 

1212 read_backend = global_config.vfs_config.read_backend.value 

1213 else: 

1214 read_backend = self.orchestrator.microscope_handler.get_primary_backend(plate_path, self.orchestrator.filemanager) 

1215 

1216 # Resolve Fiji config (lightweight operation, safe in UI thread) 

1217 from openhcs.config_framework.context_manager import config_context 

1218 from openhcs.config_framework.lazy_factory import resolve_lazy_configurations_for_serialization 

1219 from openhcs.core.config import LazyFijiStreamingConfig 

1220 

1221 current_values = self.fiji_config_form.get_current_values() 

1222 temp_config = LazyFijiStreamingConfig(**{k: v for k, v in current_values.items() if v is not None}) 

1223 

1224 with config_context(self.orchestrator.pipeline_config): 

1225 with config_context(temp_config): 

1226 fiji_config = resolve_lazy_configurations_for_serialization(temp_config) 

1227 

1228 # Get or create viewer (lightweight operation, safe in UI thread) 

1229 viewer = self.orchestrator.get_or_create_visualizer(fiji_config) 

1230 

1231 # Load and stream in background thread (HEAVY OPERATION - must not block UI) 

1232 self._load_and_stream_batch_to_fiji_async( 

1233 viewer, filenames, plate_path, read_backend, fiji_config 

1234 ) 

1235 

1236 logger.info(f"Loading and streaming batch of {len(filenames)} images to Fiji viewer on port {fiji_config.port}...") 

1237 

1238 def _load_and_stream_batch_to_napari_async(self, viewer, filenames: list, plate_path: Path, 

1239 read_backend: str, config): 

1240 """Load and stream batch of images to Napari in background thread (NEVER blocks UI).""" 

1241 import threading 

1242 

1243 def load_and_stream(): 

1244 try: 

1245 # Register that we're launching a viewer BEFORE loading (so UI shows it immediately) 

1246 from openhcs.pyqt_gui.widgets.shared.zmq_server_manager import ( 

1247 register_launching_viewer, unregister_launching_viewer 

1248 ) 

1249 

1250 # Check if viewer is already ready (quick ping with short timeout) 

1251 is_already_running = viewer.wait_for_ready(timeout=0.1) 

1252 

1253 if not is_already_running: 

1254 # Viewer is launching - register it immediately so UI shows it 

1255 register_launching_viewer(viewer.port, 'napari', len(filenames)) 

1256 logger.info(f"Registered launching Napari viewer on port {viewer.port} with {len(filenames)} queued images") 

1257 

1258 # Show loading status 

1259 self._update_status_threadsafe(f"Loading {len(filenames)} images from disk...") 

1260 

1261 # HEAVY OPERATION: Load all images (runs in background thread) 

1262 image_data_list = [] 

1263 file_paths = [] 

1264 for i, filename in enumerate(filenames, 1): 

1265 image_path = plate_path / filename 

1266 image_data = self.filemanager.load(str(image_path), read_backend) 

1267 image_data_list.append(image_data) 

1268 file_paths.append(filename) 

1269 

1270 # Update progress every 5 images 

1271 if i % 5 == 0 or i == len(filenames): 

1272 self._update_status_threadsafe(f"Loading images: {i}/{len(filenames)}...") 

1273 

1274 logger.info(f"Loaded {len(image_data_list)} images in background thread") 

1275 

1276 if not is_already_running: 

1277 # Viewer is launching - wait for it to be ready before streaming 

1278 logger.info(f"Waiting for Napari viewer on port {viewer.port} to be ready...") 

1279 

1280 # Wait for viewer to be ready before streaming 

1281 if not viewer.wait_for_ready(timeout=15.0): 

1282 unregister_launching_viewer(viewer.port) 

1283 raise RuntimeError(f"Napari viewer on port {viewer.port} failed to become ready") 

1284 

1285 logger.info(f"Napari viewer on port {viewer.port} is ready") 

1286 # Unregister from launching registry (now ready) 

1287 unregister_launching_viewer(viewer.port) 

1288 else: 

1289 logger.info(f"Napari viewer on port {viewer.port} is already running") 

1290 

1291 # Use the napari streaming backend to send the batch 

1292 from openhcs.constants.constants import Backend as BackendEnum 

1293 

1294 # Build source from subdirectory name (image viewer context) 

1295 # Use first file path to determine subdirectory 

1296 source = Path(file_paths[0]).parent.name if file_paths else 'unknown_source' 

1297 

1298 # Prepare metadata for streaming 

1299 metadata = { 

1300 'port': viewer.port, 

1301 'display_config': config, 

1302 'microscope_handler': self.orchestrator.microscope_handler, 

1303 'source': source 

1304 } 

1305 

1306 # Stream batch to Napari 

1307 self.filemanager.save_batch( 

1308 image_data_list, 

1309 file_paths, 

1310 BackendEnum.NAPARI_STREAM.value, 

1311 **metadata 

1312 ) 

1313 logger.info(f"Successfully streamed batch of {len(file_paths)} images to Napari on port {viewer.port}") 

1314 except Exception as e: 

1315 logger.error(f"Failed to load/stream batch to Napari: {e}") 

1316 # Show error in UI thread 

1317 from PyQt6.QtCore import QMetaObject, Qt 

1318 QMetaObject.invokeMethod( 

1319 self, "_show_streaming_error", 

1320 Qt.ConnectionType.QueuedConnection, 

1321 str(e) 

1322 ) 

1323 

1324 # Start loading and streaming in background thread 

1325 thread = threading.Thread(target=load_and_stream, daemon=True) 

1326 thread.start() 

1327 logger.info(f"Started background thread to load and stream {len(filenames)} images to Napari") 

1328 

1329 def _stream_batch_to_napari(self, viewer, image_data_list: list, file_paths: list, config): 

1330 """Stream batch of images to Napari viewer asynchronously (builds hyperstack).""" 

1331 # Stream in background thread to avoid blocking UI 

1332 import threading 

1333 

1334 def stream_async(): 

1335 try: 

1336 from openhcs.pyqt_gui.widgets.shared.zmq_server_manager import ( 

1337 register_launching_viewer, unregister_launching_viewer 

1338 ) 

1339 

1340 # Check if viewer is already ready (quick ping with short timeout) 

1341 # If it responds immediately, it was already running - don't show as launching 

1342 is_already_running = viewer.wait_for_ready(timeout=0.1) 

1343 

1344 if not is_already_running: 

1345 # Viewer is launching - register it and show in UI 

1346 register_launching_viewer(viewer.port, 'napari', len(file_paths)) 

1347 logger.info(f"Waiting for Napari viewer on port {viewer.port} to be ready...") 

1348 

1349 # Wait for viewer to be ready before streaming 

1350 if not viewer.wait_for_ready(timeout=15.0): 

1351 unregister_launching_viewer(viewer.port) 

1352 raise RuntimeError(f"Napari viewer on port {viewer.port} failed to become ready") 

1353 

1354 logger.info(f"Napari viewer on port {viewer.port} is ready") 

1355 # Unregister from launching registry (now ready) 

1356 unregister_launching_viewer(viewer.port) 

1357 else: 

1358 logger.info(f"Napari viewer on port {viewer.port} is already running") 

1359 

1360 # Use the napari streaming backend to send the batch 

1361 from openhcs.constants.constants import Backend as BackendEnum 

1362 

1363 # Build source from subdirectory name (image viewer context) 

1364 # Use first file path to determine subdirectory 

1365 source = Path(file_paths[0]).parent.name if file_paths else 'unknown_source' 

1366 

1367 # Prepare metadata for streaming 

1368 metadata = { 

1369 'port': viewer.port, 

1370 'display_config': config, 

1371 'microscope_handler': self.orchestrator.microscope_handler, 

1372 'source': source 

1373 } 

1374 

1375 # Stream batch to Napari 

1376 self.filemanager.save_batch( 

1377 image_data_list, 

1378 file_paths, 

1379 BackendEnum.NAPARI_STREAM.value, 

1380 **metadata 

1381 ) 

1382 logger.info(f"Successfully streamed batch of {len(file_paths)} images to Napari on port {viewer.port}") 

1383 except Exception as e: 

1384 logger.error(f"Failed to stream batch to Napari: {e}") 

1385 # Show error in UI thread 

1386 from PyQt6.QtCore import QMetaObject, Qt 

1387 QMetaObject.invokeMethod( 

1388 self, "_show_streaming_error", 

1389 Qt.ConnectionType.QueuedConnection, 

1390 str(e) 

1391 ) 

1392 

1393 # Start streaming in background thread 

1394 thread = threading.Thread(target=stream_async, daemon=True) 

1395 thread.start() 

1396 logger.info(f"Streaming batch of {len(file_paths)} images to Napari asynchronously...") 

1397 

1398 @pyqtSlot(str) 

1399 def _show_streaming_error(self, error_msg: str): 

1400 """Show streaming error in UI thread (called via QMetaObject.invokeMethod).""" 

1401 QMessageBox.warning(self, "Streaming Error", f"Failed to stream images to Napari: {error_msg}") 

1402 

1403 def _load_and_stream_batch_to_fiji_async(self, viewer, filenames: list, plate_path: Path, 

1404 read_backend: str, config): 

1405 """Load and stream batch of images to Fiji in background thread (NEVER blocks UI).""" 

1406 import threading 

1407 

1408 def load_and_stream(): 

1409 try: 

1410 # Register that we're launching a viewer BEFORE loading (so UI shows it immediately) 

1411 from openhcs.pyqt_gui.widgets.shared.zmq_server_manager import ( 

1412 register_launching_viewer, unregister_launching_viewer 

1413 ) 

1414 

1415 # Check if viewer is already ready (quick ping with short timeout) 

1416 is_already_running = viewer.wait_for_ready(timeout=0.1) 

1417 

1418 if not is_already_running: 

1419 # Viewer is launching - register it immediately so UI shows it 

1420 register_launching_viewer(viewer.port, 'fiji', len(filenames)) 

1421 logger.info(f"Registered launching Fiji viewer on port {viewer.port} with {len(filenames)} queued images") 

1422 

1423 # Show loading status 

1424 self._update_status_threadsafe(f"Loading {len(filenames)} images from disk...") 

1425 

1426 # HEAVY OPERATION: Load all images (runs in background thread) 

1427 image_data_list = [] 

1428 file_paths = [] 

1429 for i, filename in enumerate(filenames, 1): 

1430 image_path = plate_path / filename 

1431 image_data = self.filemanager.load(str(image_path), read_backend) 

1432 image_data_list.append(image_data) 

1433 file_paths.append(filename) 

1434 

1435 # Update progress every 5 images 

1436 if i % 5 == 0 or i == len(filenames): 

1437 self._update_status_threadsafe(f"Loading images: {i}/{len(filenames)}...") 

1438 

1439 logger.info(f"Loaded {len(image_data_list)} images in background thread") 

1440 

1441 if not is_already_running: 

1442 # Viewer is launching - wait for it to be ready before streaming 

1443 logger.info(f"Waiting for Fiji viewer on port {viewer.port} to be ready...") 

1444 

1445 # Wait for viewer to be ready before streaming 

1446 if not viewer.wait_for_ready(timeout=15.0): 

1447 unregister_launching_viewer(viewer.port) 

1448 raise RuntimeError(f"Fiji viewer on port {viewer.port} failed to become ready") 

1449 

1450 logger.info(f"Fiji viewer on port {viewer.port} is ready") 

1451 # Unregister from launching registry (now ready) 

1452 unregister_launching_viewer(viewer.port) 

1453 else: 

1454 logger.info(f"Fiji viewer on port {viewer.port} is already running") 

1455 

1456 # Use the Fiji streaming backend to send the batch 

1457 from openhcs.constants.constants import Backend as BackendEnum 

1458 

1459 # Build source from subdirectory name (image viewer context) 

1460 # Use first file path to determine subdirectory 

1461 source = Path(file_paths[0]).parent.name if file_paths else 'unknown_source' 

1462 

1463 # Prepare metadata for streaming 

1464 metadata = { 

1465 'port': viewer.port, 

1466 'display_config': config, 

1467 'microscope_handler': self.orchestrator.microscope_handler, 

1468 'source': source 

1469 } 

1470 

1471 # Stream batch to Fiji 

1472 logger.info(f"🚀 IMAGE BROWSER: Calling save_batch with {len(image_data_list)} images") 

1473 self.filemanager.save_batch( 

1474 image_data_list, 

1475 file_paths, 

1476 BackendEnum.FIJI_STREAM.value, 

1477 **metadata 

1478 ) 

1479 logger.info(f"✅ IMAGE BROWSER: Successfully streamed batch of {len(file_paths)} images to Fiji on port {viewer.port}") 

1480 except Exception as e: 

1481 logger.error(f"Failed to load/stream batch to Fiji: {e}") 

1482 # Show error in UI thread 

1483 from PyQt6.QtCore import QMetaObject, Qt 

1484 QMetaObject.invokeMethod( 

1485 self, "_show_fiji_streaming_error", 

1486 Qt.ConnectionType.QueuedConnection, 

1487 str(e) 

1488 ) 

1489 

1490 # Start loading and streaming in background thread 

1491 thread = threading.Thread(target=load_and_stream, daemon=True) 

1492 thread.start() 

1493 logger.info(f"Started background thread to load and stream {len(filenames)} images to Fiji") 

1494 

1495 def _stream_batch_to_fiji(self, viewer, image_data_list: list, file_paths: list, config): 

1496 """Stream batch of images to Fiji viewer asynchronously (builds hyperstack).""" 

1497 # Stream in background thread to avoid blocking UI 

1498 import threading 

1499 

1500 def stream_async(): 

1501 try: 

1502 from openhcs.pyqt_gui.widgets.shared.zmq_server_manager import ( 

1503 register_launching_viewer, unregister_launching_viewer 

1504 ) 

1505 

1506 # Check if viewer is already ready (quick ping with short timeout) 

1507 # If it responds immediately, it was already running - don't show as launching 

1508 is_already_running = viewer.wait_for_ready(timeout=0.1) 

1509 

1510 if not is_already_running: 

1511 # Viewer is launching - register it and show in UI 

1512 register_launching_viewer(viewer.port, 'fiji', len(file_paths)) 

1513 logger.info(f"Waiting for Fiji viewer on port {viewer.port} to be ready...") 

1514 

1515 # Wait for viewer to be ready before streaming 

1516 if not viewer.wait_for_ready(timeout=15.0): 

1517 unregister_launching_viewer(viewer.port) 

1518 raise RuntimeError(f"Fiji viewer on port {viewer.port} failed to become ready") 

1519 

1520 logger.info(f"Fiji viewer on port {viewer.port} is ready") 

1521 # Unregister from launching registry (now ready) 

1522 unregister_launching_viewer(viewer.port) 

1523 else: 

1524 logger.info(f"Fiji viewer on port {viewer.port} is already running") 

1525 

1526 # Use the Fiji streaming backend to send the batch 

1527 from openhcs.constants.constants import Backend as BackendEnum 

1528 

1529 # Build source from subdirectory name (image viewer context) 

1530 # Use first file path to determine subdirectory 

1531 source = Path(file_paths[0]).parent.name if file_paths else 'unknown_source' 

1532 

1533 # Prepare metadata for streaming 

1534 metadata = { 

1535 'port': viewer.port, 

1536 'display_config': config, 

1537 'microscope_handler': self.orchestrator.microscope_handler, 

1538 'source': source 

1539 } 

1540 

1541 # Stream batch to Fiji 

1542 self.filemanager.save_batch( 

1543 image_data_list, 

1544 file_paths, 

1545 BackendEnum.FIJI_STREAM.value, 

1546 **metadata 

1547 ) 

1548 logger.info(f"Successfully streamed batch of {len(file_paths)} images to Fiji on port {viewer.port}") 

1549 except Exception as e: 

1550 logger.error(f"Failed to stream batch to Fiji: {e}") 

1551 # Show error in UI thread 

1552 from PyQt6.QtCore import QMetaObject, Qt 

1553 QMetaObject.invokeMethod( 

1554 self, "_show_fiji_streaming_error", 

1555 Qt.ConnectionType.QueuedConnection, 

1556 str(e) 

1557 ) 

1558 

1559 # Start streaming in background thread 

1560 thread = threading.Thread(target=stream_async, daemon=True) 

1561 thread.start() 

1562 logger.info(f"Streaming batch of {len(file_paths)} images to Fiji asynchronously...") 

1563 

1564 @pyqtSlot(str) 

1565 def _show_fiji_streaming_error(self, error_msg: str): 

1566 """Show Fiji streaming error in UI thread.""" 

1567 QMessageBox.warning(self, "Streaming Error", f"Failed to stream images to Fiji: {error_msg}") 

1568 

1569 def _stream_rois_to_napari(self, rois: list, roi_json_path: Path): 

1570 """Stream ROIs to Napari viewer.""" 

1571 try: 

1572 # Get Napari config using current form values 

1573 from openhcs.config_framework.context_manager import config_context 

1574 from openhcs.config_framework.lazy_factory import resolve_lazy_configurations_for_serialization 

1575 from openhcs.core.config import LazyNapariStreamingConfig 

1576 

1577 current_values = self.napari_config_form.get_current_values() 

1578 temp_config = LazyNapariStreamingConfig(**{k: v for k, v in current_values.items() if v is not None}) 

1579 

1580 with config_context(self.orchestrator.pipeline_config): 

1581 with config_context(temp_config): 

1582 napari_config = resolve_lazy_configurations_for_serialization(temp_config) 

1583 

1584 # Get or create viewer 

1585 viewer = self.orchestrator.get_or_create_visualizer(napari_config) 

1586 

1587 # Stream ROIs using filemanager.save() - same as pipeline execution 

1588 # Pass display_config and microscope_handler just like image streaming does 

1589 from openhcs.constants.constants import Backend as BackendEnum 

1590 from pathlib import Path 

1591 

1592 # Build source from subdirectory name (image viewer context) 

1593 source = Path(roi_json_path).parent.name 

1594 

1595 self.filemanager.save( 

1596 rois, 

1597 roi_json_path, 

1598 BackendEnum.NAPARI_STREAM.value, 

1599 host='localhost', 

1600 port=napari_config.port, 

1601 display_config=napari_config, 

1602 microscope_handler=self.orchestrator.microscope_handler, 

1603 source=source 

1604 ) 

1605 

1606 logger.info(f"Streamed {len(rois)} ROIs to Napari on port {napari_config.port}") 

1607 

1608 except Exception as e: 

1609 logger.error(f"Failed to stream ROIs to Napari: {e}") 

1610 raise 

1611 

1612 def _stream_rois_to_fiji(self, rois: list, roi_json_path: Path): 

1613 """Stream ROIs to Fiji viewer.""" 

1614 try: 

1615 # Get Fiji config using current form values 

1616 from openhcs.config_framework.context_manager import config_context 

1617 from openhcs.config_framework.lazy_factory import resolve_lazy_configurations_for_serialization 

1618 from openhcs.core.config import LazyFijiStreamingConfig 

1619 

1620 current_values = self.fiji_config_form.get_current_values() 

1621 temp_config = LazyFijiStreamingConfig(**{k: v for k, v in current_values.items() if v is not None}) 

1622 

1623 with config_context(self.orchestrator.pipeline_config): 

1624 with config_context(temp_config): 

1625 fiji_config = resolve_lazy_configurations_for_serialization(temp_config) 

1626 

1627 # Get or create viewer 

1628 viewer = self.orchestrator.get_or_create_visualizer(fiji_config) 

1629 

1630 # Stream ROIs using filemanager.save() - same as pipeline execution 

1631 # Pass display_config and microscope_handler just like image streaming does 

1632 from openhcs.constants.constants import Backend as BackendEnum 

1633 from pathlib import Path 

1634 

1635 # Build source from subdirectory name (image viewer context) 

1636 source = Path(roi_json_path).parent.name 

1637 

1638 self.filemanager.save( 

1639 rois, 

1640 roi_json_path, 

1641 BackendEnum.FIJI_STREAM.value, 

1642 host='localhost', 

1643 port=fiji_config.port, 

1644 display_config=fiji_config, 

1645 microscope_handler=self.orchestrator.microscope_handler, 

1646 source=source 

1647 ) 

1648 

1649 logger.info(f"Streamed {len(rois)} ROIs to Fiji on port {fiji_config.port}") 

1650 

1651 except Exception as e: 

1652 logger.error(f"Failed to stream ROIs to Fiji: {e}") 

1653 raise 

1654 

1655 def _display_csv_file(self, csv_path: Path): 

1656 """Display CSV file in preview area.""" 

1657 try: 

1658 import pandas as pd 

1659 

1660 # Read CSV 

1661 df = pd.read_csv(csv_path) 

1662 

1663 # Format as string (show first 100 rows) 

1664 if len(df) > 100: 

1665 preview_text = f"Showing first 100 of {len(df)} rows:\n\n" 

1666 preview_text += df.head(100).to_string(index=False) 

1667 else: 

1668 preview_text = df.to_string(index=False) 

1669 

1670 # Show preview 

1671 self.csv_preview.setPlainText(preview_text) 

1672 self.csv_preview.setVisible(True) 

1673 

1674 logger.info(f"Displayed CSV file: {csv_path.name} ({len(df)} rows)") 

1675 

1676 except Exception as e: 

1677 logger.error(f"Failed to display CSV file: {e}") 

1678 self.csv_preview.setPlainText(f"Error loading CSV: {e}") 

1679 self.csv_preview.setVisible(True) 

1680 

1681 def _display_json_file(self, json_path: Path): 

1682 """Display JSON file in preview area.""" 

1683 try: 

1684 import json 

1685 

1686 # Read JSON 

1687 with open(json_path, 'r') as f: 

1688 data = json.load(f) 

1689 

1690 # Format as pretty JSON 

1691 preview_text = json.dumps(data, indent=2) 

1692 

1693 # Show preview 

1694 self.csv_preview.setPlainText(preview_text) 

1695 self.csv_preview.setVisible(True) 

1696 

1697 logger.info(f"Displayed JSON file: {json_path.name}") 

1698 

1699 except Exception as e: 

1700 logger.error(f"Failed to display JSON file: {e}") 

1701 self.csv_preview.setPlainText(f"Error loading JSON: {e}") 

1702 self.csv_preview.setVisible(True) 

1703 

1704 def cleanup(self): 

1705 """Clean up resources before widget destruction.""" 

1706 # Stop global ack listener thread 

1707 from openhcs.runtime.zmq_base import stop_global_ack_listener 

1708 stop_global_ack_listener() 

1709 

1710 # Cleanup ZMQ server manager widget 

1711 if hasattr(self, 'zmq_manager') and self.zmq_manager: 

1712 self.zmq_manager.cleanup() 

1713 

1714 # ========== Plate View Methods ========== 

1715 

1716 def _toggle_plate_view(self, checked: bool): 

1717 """Toggle plate view visibility.""" 

1718 # If detached, just show/hide the window 

1719 if self.plate_view_detached_window: 

1720 self.plate_view_detached_window.setVisible(checked) 

1721 if checked: 

1722 self.plate_view_toggle_btn.setText("Hide Plate View") 

1723 else: 

1724 self.plate_view_toggle_btn.setText("Show Plate View") 

1725 return 

1726 

1727 # Otherwise toggle in main layout 

1728 self.plate_view_widget.setVisible(checked) 

1729 

1730 if checked: 

1731 self.plate_view_toggle_btn.setText("Hide Plate View") 

1732 # Update plate view with current images 

1733 self._update_plate_view() 

1734 else: 

1735 self.plate_view_toggle_btn.setText("Show Plate View") 

1736 

1737 def _detach_plate_view(self): 

1738 """Detach plate view to external window.""" 

1739 if self.plate_view_detached_window: 

1740 # Already detached, just show it 

1741 self.plate_view_detached_window.show() 

1742 self.plate_view_detached_window.raise_() 

1743 return 

1744 

1745 from PyQt6.QtWidgets import QDialog 

1746 

1747 # Create detached window 

1748 self.plate_view_detached_window = QDialog(self) 

1749 self.plate_view_detached_window.setWindowTitle("Plate View") 

1750 self.plate_view_detached_window.setWindowFlags(Qt.WindowType.Dialog) 

1751 self.plate_view_detached_window.setMinimumSize(600, 400) 

1752 self.plate_view_detached_window.resize(800, 600) 

1753 

1754 # Create layout for window 

1755 window_layout = QVBoxLayout(self.plate_view_detached_window) 

1756 window_layout.setContentsMargins(10, 10, 10, 10) 

1757 

1758 # Add reattach button 

1759 reattach_btn = QPushButton("⬅ Reattach to Main Window") 

1760 reattach_btn.setStyleSheet(self.style_gen.generate_button_style()) 

1761 reattach_btn.clicked.connect(self._reattach_plate_view) 

1762 window_layout.addWidget(reattach_btn) 

1763 

1764 # Move plate view widget to window 

1765 self.plate_view_widget.setParent(self.plate_view_detached_window) 

1766 self.plate_view_widget.setVisible(True) 

1767 window_layout.addWidget(self.plate_view_widget) 

1768 

1769 # Connect close event to reattach 

1770 self.plate_view_detached_window.closeEvent = lambda event: self._on_detached_window_closed(event) 

1771 

1772 # Show window 

1773 self.plate_view_detached_window.show() 

1774 

1775 logger.info("Plate view detached to external window") 

1776 

1777 def _reattach_plate_view(self): 

1778 """Reattach plate view to main layout.""" 

1779 if not self.plate_view_detached_window: 

1780 return 

1781 

1782 # Store reference before clearing 

1783 window = self.plate_view_detached_window 

1784 self.plate_view_detached_window = None 

1785 

1786 # Move plate view widget back to splitter 

1787 self.plate_view_widget.setParent(self) 

1788 self.middle_splitter.insertWidget(0, self.plate_view_widget) 

1789 self.plate_view_widget.setVisible(self.plate_view_toggle_btn.isChecked()) 

1790 

1791 # Close and cleanup detached window 

1792 window.close() 

1793 window.deleteLater() 

1794 

1795 logger.info("Plate view reattached to main window") 

1796 

1797 def _on_detached_window_closed(self, event): 

1798 """Handle detached window close event - reattach automatically.""" 

1799 # Only reattach if window still exists (not already reattached) 

1800 if self.plate_view_detached_window: 

1801 # Clear reference first to prevent double-close 

1802 window = self.plate_view_detached_window 

1803 self.plate_view_detached_window = None 

1804 

1805 # Move plate view widget back to splitter 

1806 self.plate_view_widget.setParent(self) 

1807 self.middle_splitter.insertWidget(0, self.plate_view_widget) 

1808 self.plate_view_widget.setVisible(self.plate_view_toggle_btn.isChecked()) 

1809 

1810 logger.info("Plate view reattached to main window (window closed)") 

1811 

1812 event.accept() 

1813 

1814 def _on_wells_selected(self, well_ids: Set[str]): 

1815 """Handle well selection from plate view.""" 

1816 self.selected_wells = well_ids 

1817 self._apply_combined_filters() 

1818 

1819 def _update_plate_view(self): 

1820 """Update plate view with current file data (images + results).""" 

1821 # Extract all well IDs from current files (filter out failures) 

1822 well_ids = set() 

1823 for filename, metadata in self.all_files.items(): 

1824 try: 

1825 well_id = self._extract_well_id(metadata) 

1826 well_ids.add(well_id) 

1827 except (KeyError, ValueError): 

1828 # Skip files without well metadata (e.g., plate-level files) 

1829 pass 

1830 

1831 # Detect plate dimensions and build coordinate mapping 

1832 plate_dimensions = self._detect_plate_dimensions(well_ids) if well_ids else None 

1833 

1834 # Build mapping from (row_index, col_index) to actual well_id 

1835 # This handles different well ID formats (A01 vs R01C01) 

1836 coord_to_well = {} 

1837 parser = self.orchestrator.microscope_handler.parser 

1838 for well_id in well_ids: 

1839 row, col = parser.extract_component_coordinates(well_id) 

1840 # Convert row letter to index (A=1, B=2, etc.) 

1841 row_idx = sum((ord(c.upper()) - ord('A') + 1) * (26 ** i) 

1842 for i, c in enumerate(reversed(row))) 

1843 coord_to_well[(row_idx, int(col))] = well_id 

1844 

1845 # Update plate view with well IDs, dimensions, and coordinate mapping 

1846 self.plate_view_widget.set_available_wells(well_ids, plate_dimensions, coord_to_well) 

1847 

1848 # Handle subdirectory selection 

1849 current_folder = self._get_current_folder() 

1850 subdirs = self._detect_plate_subdirs(current_folder) 

1851 self.plate_view_widget.set_subdirectories(subdirs) 

1852 

1853 def _matches_wells(self, filename: str, metadata: dict) -> bool: 

1854 """Check if image matches selected wells.""" 

1855 try: 

1856 well_id = self._extract_well_id(metadata) 

1857 return well_id in self.selected_wells 

1858 except (KeyError, ValueError): 

1859 # Image has no well metadata, doesn't match well filter 

1860 return False 

1861 

1862 def _get_current_folder(self) -> Optional[str]: 

1863 """Get currently selected folder path from tree.""" 

1864 selected_items = self.folder_tree.selectedItems() 

1865 if selected_items: 

1866 folder_path = selected_items[0].data(0, Qt.ItemDataRole.UserRole) 

1867 return folder_path 

1868 return None 

1869 

1870 def _detect_plate_subdirs(self, current_folder: Optional[str]) -> List[str]: 

1871 """ 

1872 Detect plate output subdirectories. 

1873 

1874 Logic: 

1875 - If at plate root (no folder selected or root selected), look for subdirs with well images 

1876 - If in a subdir, return empty list (already in a plate output) 

1877 

1878 Returns list of subdirectory names (not full paths). 

1879 """ 

1880 if not self.orchestrator: 

1881 return [] 

1882 

1883 plate_path = self.orchestrator.plate_path 

1884 

1885 # If no folder selected or root selected, we're at plate root 

1886 if current_folder is None: 

1887 base_path = plate_path 

1888 else: 

1889 # Check if current folder is plate root 

1890 if str(Path(current_folder)) == str(plate_path): 

1891 base_path = plate_path 

1892 else: 

1893 # Already in a subdirectory, no subdirs to show 

1894 return [] 

1895 

1896 # Find immediate subdirectories that contain well files 

1897 subdirs_with_wells = set() 

1898 

1899 for filename in self.all_files.keys(): 

1900 file_path = Path(filename) 

1901 

1902 # Check if file is in a subdirectory of base_path 

1903 try: 

1904 relative = file_path.relative_to(base_path) 

1905 parts = relative.parts 

1906 

1907 # If file is in a subdirectory (not directly in base_path) 

1908 if len(parts) > 1: 

1909 subdir_name = parts[0] 

1910 

1911 # Check if this file has well metadata 

1912 metadata = self.all_files[filename] 

1913 try: 

1914 self._extract_well_id(metadata) 

1915 # Has well metadata, add subdir 

1916 subdirs_with_wells.add(subdir_name) 

1917 except (KeyError, ValueError): 

1918 # No well metadata, skip 

1919 pass 

1920 except ValueError: 

1921 # File not relative to base_path, skip 

1922 pass 

1923 

1924 return sorted(list(subdirs_with_wells)) 

1925 

1926 # ========== Plate View Helper Methods ========== 

1927 

1928 def _extract_well_id(self, metadata: dict) -> str: 

1929 """ 

1930 Extract well ID from metadata. 

1931 

1932 Returns well ID like 'A01', 'B03', 'R01C03', etc. 

1933 Raises KeyError if metadata missing 'well' component. 

1934 """ 

1935 # Well ID is a single component in metadata 

1936 return str(metadata['well']) 

1937 

1938 def _detect_plate_dimensions(self, well_ids: Set[str]) -> tuple[int, int]: 

1939 """ 

1940 Auto-detect plate dimensions from well IDs. 

1941 

1942 Uses existing infrastructure: 

1943 - FilenameParser.extract_component_coordinates() to parse each well ID 

1944 - Determines max row/col from parsed coordinates 

1945 

1946 Returns (rows, cols) tuple. 

1947 Raises ValueError if well IDs are invalid format. 

1948 """ 

1949 parser = self.orchestrator.microscope_handler.parser 

1950 

1951 rows = set() 

1952 cols = set() 

1953 

1954 for well_id in well_ids: 

1955 # REUSE: Parser's extract_component_coordinates (fail loud if invalid) 

1956 row, col = parser.extract_component_coordinates(well_id) 

1957 rows.add(row) 

1958 cols.add(int(col)) 

1959 

1960 # Convert row letters to indices (A=1, B=2, AA=27, etc.) 

1961 row_indices = [ 

1962 sum((ord(c.upper()) - ord('A') + 1) * (26 ** i) 

1963 for i, c in enumerate(reversed(row))) 

1964 for row in rows 

1965 ] 

1966 

1967 return (max(row_indices), max(cols)) 

1968 

1969 def _update_status_threadsafe(self, message: str): 

1970 """Update status label from any thread (thread-safe). 

1971 

1972 Args: 

1973 message: Status message to display 

1974 """ 

1975 self._status_update_signal.emit(message) 

1976 

1977 @pyqtSlot(str) 

1978 def _update_status_label(self, message: str): 

1979 """Update status label (called on main thread via signal).""" 

1980 self.info_label.setText(message) 

1981