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
« prev ^ index » next coverage.py v7.11.0, created at 2025-11-04 02:09 +0000
1"""
2Image Browser Widget for PyQt6 GUI.
4Displays a table of all image files from plate metadata and allows users to
5view them in Napari with configurable display settings.
6"""
8import logging
9from pathlib import Path
10from typing import Optional, List, Dict, Set, Any
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
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
27logger = logging.getLogger(__name__)
30class ImageBrowserWidget(QWidget):
31 """
32 Image browser widget that displays all image files from plate metadata.
34 Users can click on files to view them in Napari with configurable settings
35 from the current PipelineConfig.
36 """
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
42 def __init__(self, orchestrator=None, color_scheme: Optional[PyQt6ColorScheme] = None, parent=None):
43 super().__init__(parent)
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)
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
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)
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
70 # Column filter panel
71 self.column_filter_panel = None
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()
77 self.init_ui()
79 # Connect internal signal for thread-safe status updates
80 self._status_update_signal.connect(self._update_status_label)
82 # Load images if orchestrator is provided
83 if self.orchestrator:
84 self.load_images()
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
92 # Search input row with buttons on the right
93 search_layout = QHBoxLayout()
94 search_layout.setSpacing(10)
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
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
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
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
132 layout.addLayout(search_layout)
134 # Create main splitter (tree+filters | table | config)
135 main_splitter = QSplitter(Qt.Orientation.Horizontal)
137 # Left panel: Vertical splitter for Folder tree + Column filters
138 left_splitter = QSplitter(Qt.Orientation.Vertical)
140 # Folder tree
141 tree_widget = self._create_folder_tree()
142 left_splitter.addWidget(tree_widget)
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)
152 # Set initial sizes: filters get more space (20% tree, 80% filters)
153 left_splitter.setSizes([100, 400])
155 main_splitter.addWidget(left_splitter)
157 # Middle: Vertical splitter for plate view and tabs
158 self.middle_splitter = QSplitter(Qt.Orientation.Vertical)
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)
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)
172 # Set initial sizes (30% plate view, 70% table when visible)
173 self.middle_splitter.setSizes([150, 350])
175 main_splitter.addWidget(self.middle_splitter)
177 # Right: Napari config panel + instance manager
178 right_panel = self._create_right_panel()
179 main_splitter.addWidget(right_panel)
181 # Set initial splitter sizes (20% tree, 50% middle, 30% config)
182 main_splitter.setSizes([200, 500, 300])
184 # Add splitter with stretch factor to fill vertical space
185 layout.addWidget(main_splitter, 1)
187 # Connect selection change
188 self.file_table.itemSelectionChanged.connect(self.on_selection_changed)
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)
196 # Apply styling
197 tree.setStyleSheet(self.style_gen.generate_tree_widget_style())
199 # Connect selection to filter table
200 tree.itemSelectionChanged.connect(self.on_folder_selection_changed)
202 # Store reference
203 self.folder_tree = tree
205 return tree
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)
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"])
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)
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
230 # Apply styling
231 self.file_table.setStyleSheet(self.style_gen.generate_table_widget_style())
233 # Connect double-click to view in enabled viewer(s)
234 self.file_table.cellDoubleClicked.connect(self.on_file_double_clicked)
236 layout.addWidget(self.file_table)
238 return table_container
240 # Removed _create_results_widget - now using unified file table
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)
250 # Tab bar row with view buttons
251 tab_row = QHBoxLayout()
252 tab_row.setContentsMargins(0, 0, 0, 0)
253 tab_row.setSpacing(5)
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())
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")
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")
268 # Update tab text when configs are enabled/disabled
269 self._update_tab_labels()
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
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
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
290 layout.addLayout(tab_row)
292 # Vertical splitter for configs and instance manager
293 vertical_splitter = QSplitter(Qt.Orientation.Vertical)
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)
303 # Instance manager panel
304 instance_panel = self._create_instance_manager_panel()
305 vertical_splitter.addWidget(instance_panel)
307 # Set initial sizes (80% configs, 20% instance manager)
308 vertical_splitter.setSizes([400, 100])
310 layout.addWidget(vertical_splitter)
312 return container
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
318 panel = QGroupBox()
319 layout = QVBoxLayout(panel)
320 layout.setContentsMargins(5, 5, 5, 5)
321 layout.setSpacing(5)
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)
329 # Create lazy Napari config instance
330 from openhcs.core.config import LazyNapariStreamingConfig
331 self.lazy_napari_config = LazyNapariStreamingConfig()
333 # Create parameter form for the lazy config
334 from openhcs.pyqt_gui.widgets.shared.parameter_form_manager import ParameterFormManager
336 # Set up context for placeholder resolution
337 if self.orchestrator:
338 context_obj = self.orchestrator.pipeline_config
339 else:
340 context_obj = None
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 )
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)
358 return panel
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
364 panel = QGroupBox()
365 layout = QVBoxLayout(panel)
366 layout.setContentsMargins(5, 5, 5, 5)
367 layout.setSpacing(5)
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)
375 # Create lazy Fiji config instance
376 from openhcs.config_framework.lazy_factory import LazyFijiStreamingConfig
377 self.lazy_fiji_config = LazyFijiStreamingConfig()
379 # Create parameter form for the lazy config
380 from openhcs.pyqt_gui.widgets.shared.parameter_form_manager import ParameterFormManager
382 # Set up context for placeholder resolution
383 if self.orchestrator:
384 context_obj = self.orchestrator.pipeline_config
385 else:
386 context_obj = None
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 )
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)
404 # Initially disable the form (checkbox is unchecked by default)
405 self.fiji_config_form.setEnabled(False)
407 return panel
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()
414 napari_label = "Napari ✓" if napari_enabled else "Napari"
415 fiji_label = "Fiji ✓" if fiji_enabled else "Fiji"
417 self.streaming_tabs.setTabText(self.napari_tab_index, napari_label)
418 self.streaming_tabs.setTabText(self.fiji_tab_index, fiji_label)
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()
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()
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
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]
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 )
455 return zmq_manager
457 def set_orchestrator(self, orchestrator):
458 """Set the orchestrator and load images."""
459 self.orchestrator = orchestrator
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")
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()
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()
476 self.load_images()
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()
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()
494 # Update plate view for new folder
495 if self.plate_view_widget and self.plate_view_widget.isVisible():
496 self._update_plate_view()
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()
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"
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 }
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 }
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
548 # Update table with combined filters
549 self._populate_table(result)
550 logger.debug(f"Combined filters: {len(result)} images shown")
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.
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.
560 Args:
561 metadata_key: Metadata key (e.g., "channel", "site", "well")
562 raw_value: Raw value from parser (e.g., 1, 2, "A01")
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'
571 # Convert to string for lookup
572 value_str = str(raw_value)
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 }
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}")
597 # Fallback to raw value only
598 return value_str
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
605 # Clear existing filters
606 self.column_filter_panel.clear_all_filters()
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)
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)))
623 # Show filter panel if we have filters
624 if self.column_filter_panel.column_filters:
625 self.column_filter_panel.setVisible(True)
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)
632 # Connect well filter changes to sync back to plate view
633 well_filter.filter_changed.connect(self._on_well_filter_changed)
635 logger.debug(f"Built {len(self.column_filter_panel.column_filters)} column filters")
637 def _on_column_filters_changed(self):
638 """Handle column filter changes."""
639 self._apply_combined_filters()
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()
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
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)
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 )
669 # Update search service with current files
670 self._search_service.update_items(self.all_files)
672 # Perform search using shared service
673 self.filtered_files = self._search_service.filter(search_term)
675 # Apply combined filters (search + folder + column filters)
676 self._apply_combined_filters()
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
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__}")
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")
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
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
714 logger.info(f"IMAGE BROWSER: Built all_images dict with {len(self.all_images)} entries")
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 = {}
722 # Load results and merge with images
723 self.load_results()
725 # Merge images and results into unified all_files dictionary
726 self.all_files = {**self.all_images, **self.all_results}
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())
733 # Remove 'filename' from keys (it's always the first column)
734 all_keys.discard('filename')
736 # Sort keys for consistent column order (extension first, then alphabetical)
737 self.metadata_keys = sorted(all_keys, key=lambda k: (k != 'extension', k))
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)
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)
749 # Initialize filtered files to all files
750 self.filtered_files = self.all_files.copy()
752 # Build folder tree from file paths
753 self._build_folder_tree()
755 # Build column filters from metadata
756 self._build_column_filters()
758 # Populate table
759 self._populate_table(self.filtered_files)
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)")
767 # Update plate view if visible
768 if self.plate_view_widget and self.plate_view_widget.isVisible():
769 self._update_plate_view()
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 = {}
775 if not self.orchestrator:
776 logger.warning("IMAGE BROWSER RESULTS: No orchestrator available")
777 return
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
785 # Load metadata JSON directly
786 from openhcs.io.metadata_writer import get_metadata_path
787 import json
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
795 with open(metadata_path) as f:
796 metadata = json.load(f)
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
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
817 if not results_dir:
818 logger.warning("IMAGE BROWSER RESULTS: No results_dir found in metadata")
819 return
821 logger.info(f"IMAGE BROWSER RESULTS: plate_path = {plate_path}")
822 logger.info(f"IMAGE BROWSER RESULTS: Resolved results directory = {results_dir}")
824 if not results_dir.exists():
825 logger.warning(f"IMAGE BROWSER RESULTS: Results directory does not exist: {results_dir}")
826 return
828 # Get parser from orchestrator for filename parsing
829 handler = self.orchestrator.microscope_handler
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})")
840 # Determine file type using FileFormat registry
841 from openhcs.constants.constants import FileFormat
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})")
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)
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"
869 # Parse ONLY the filename (not the full path) to extract metadata
870 parsed = handler.parser.parse_filename(file_path.name)
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 }
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}")
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
891 logger.info(f"IMAGE BROWSER RESULTS: Scanned {file_count} total files, matched {len(self.all_results)} result files")
893 except Exception as e:
894 logger.error(f"IMAGE BROWSER RESULTS: Failed to load results: {e}", exc_info=True)
896 # Removed _populate_results_table - now using unified _populate_table
897 # Removed on_result_double_clicked - now using unified on_file_double_clicked
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
904 # Load ROIs from .roi.zip archive
905 rois = load_rois_from_zip(roi_zip_path)
907 if not rois:
908 QMessageBox.information(self, "No ROIs", f"No ROIs found in {roi_zip_path.name}")
909 return
911 # Check which viewers are enabled
912 napari_enabled = self.napari_enable_checkbox.isChecked()
913 fiji_enabled = self.fiji_enable_checkbox.isChecked()
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
923 # Stream to enabled viewers
924 if napari_enabled:
925 self._stream_rois_to_napari(rois, roi_zip_path)
927 if fiji_enabled:
928 self._stream_rois_to_fiji(rois, roi_zip_path)
930 logger.info(f"Streamed {len(rois)} ROIs from {roi_zip_path.name}")
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}")
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)
941 # Populate rows
942 for i, (filename, metadata) in enumerate(files_dict.items()):
943 self.file_table.insertRow(i)
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)
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))
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)
965 self.folder_tree.clear()
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)
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)
982 # Sort folders for consistent display
983 sorted_folders = sorted(folders)
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
1004 # Start with everything collapsed (user can expand to explore)
1005 root_item.setExpanded(False)
1007 # Restore previous selection if it still exists
1008 if selected_folder is not None:
1009 self._restore_folder_selection(selected_folder, folder_items)
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())
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)
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')
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()
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()
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 )
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)
1064 if not file_path:
1065 logger.error(f"Could not find full path for result file: {filename}")
1066 return
1068 file_type = file_info['type']
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)])
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
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)
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)
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)
1110 # Stream image files as a batch
1111 if image_filenames:
1112 self._load_and_stream_batch_to_napari(image_filenames)
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}")
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
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)
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)
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}")
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)
1151 # Stream image files as a batch
1152 if image_filenames:
1153 self._load_and_stream_batch_to_fiji(image_filenames)
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}")
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")
1164 # Get plate path
1165 plate_path = Path(self.orchestrator.plate_path)
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)
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)
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
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})
1184 with config_context(self.orchestrator.pipeline_config):
1185 with config_context(temp_config):
1186 napari_config = resolve_lazy_configurations_for_serialization(temp_config)
1188 # Get or create viewer (lightweight operation, safe in UI thread)
1189 viewer = self.orchestrator.get_or_create_visualizer(napari_config)
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 )
1196 logger.info(f"Loading and streaming batch of {len(filenames)} images to Napari viewer on port {napari_config.port}...")
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")
1203 # Get plate path
1204 plate_path = Path(self.orchestrator.plate_path)
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)
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)
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
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})
1224 with config_context(self.orchestrator.pipeline_config):
1225 with config_context(temp_config):
1226 fiji_config = resolve_lazy_configurations_for_serialization(temp_config)
1228 # Get or create viewer (lightweight operation, safe in UI thread)
1229 viewer = self.orchestrator.get_or_create_visualizer(fiji_config)
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 )
1236 logger.info(f"Loading and streaming batch of {len(filenames)} images to Fiji viewer on port {fiji_config.port}...")
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
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 )
1250 # Check if viewer is already ready (quick ping with short timeout)
1251 is_already_running = viewer.wait_for_ready(timeout=0.1)
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")
1258 # Show loading status
1259 self._update_status_threadsafe(f"Loading {len(filenames)} images from disk...")
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)
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)}...")
1274 logger.info(f"Loaded {len(image_data_list)} images in background thread")
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...")
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")
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")
1291 # Use the napari streaming backend to send the batch
1292 from openhcs.constants.constants import Backend as BackendEnum
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'
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 }
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 )
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")
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
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 )
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)
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...")
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")
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")
1360 # Use the napari streaming backend to send the batch
1361 from openhcs.constants.constants import Backend as BackendEnum
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'
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 }
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 )
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...")
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}")
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
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 )
1415 # Check if viewer is already ready (quick ping with short timeout)
1416 is_already_running = viewer.wait_for_ready(timeout=0.1)
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")
1423 # Show loading status
1424 self._update_status_threadsafe(f"Loading {len(filenames)} images from disk...")
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)
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)}...")
1439 logger.info(f"Loaded {len(image_data_list)} images in background thread")
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...")
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")
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")
1456 # Use the Fiji streaming backend to send the batch
1457 from openhcs.constants.constants import Backend as BackendEnum
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'
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 }
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 )
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")
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
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 )
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)
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...")
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")
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")
1526 # Use the Fiji streaming backend to send the batch
1527 from openhcs.constants.constants import Backend as BackendEnum
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'
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 }
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 )
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...")
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}")
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
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})
1580 with config_context(self.orchestrator.pipeline_config):
1581 with config_context(temp_config):
1582 napari_config = resolve_lazy_configurations_for_serialization(temp_config)
1584 # Get or create viewer
1585 viewer = self.orchestrator.get_or_create_visualizer(napari_config)
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
1592 # Build source from subdirectory name (image viewer context)
1593 source = Path(roi_json_path).parent.name
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 )
1606 logger.info(f"Streamed {len(rois)} ROIs to Napari on port {napari_config.port}")
1608 except Exception as e:
1609 logger.error(f"Failed to stream ROIs to Napari: {e}")
1610 raise
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
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})
1623 with config_context(self.orchestrator.pipeline_config):
1624 with config_context(temp_config):
1625 fiji_config = resolve_lazy_configurations_for_serialization(temp_config)
1627 # Get or create viewer
1628 viewer = self.orchestrator.get_or_create_visualizer(fiji_config)
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
1635 # Build source from subdirectory name (image viewer context)
1636 source = Path(roi_json_path).parent.name
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 )
1649 logger.info(f"Streamed {len(rois)} ROIs to Fiji on port {fiji_config.port}")
1651 except Exception as e:
1652 logger.error(f"Failed to stream ROIs to Fiji: {e}")
1653 raise
1655 def _display_csv_file(self, csv_path: Path):
1656 """Display CSV file in preview area."""
1657 try:
1658 import pandas as pd
1660 # Read CSV
1661 df = pd.read_csv(csv_path)
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)
1670 # Show preview
1671 self.csv_preview.setPlainText(preview_text)
1672 self.csv_preview.setVisible(True)
1674 logger.info(f"Displayed CSV file: {csv_path.name} ({len(df)} rows)")
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)
1681 def _display_json_file(self, json_path: Path):
1682 """Display JSON file in preview area."""
1683 try:
1684 import json
1686 # Read JSON
1687 with open(json_path, 'r') as f:
1688 data = json.load(f)
1690 # Format as pretty JSON
1691 preview_text = json.dumps(data, indent=2)
1693 # Show preview
1694 self.csv_preview.setPlainText(preview_text)
1695 self.csv_preview.setVisible(True)
1697 logger.info(f"Displayed JSON file: {json_path.name}")
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)
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()
1710 # Cleanup ZMQ server manager widget
1711 if hasattr(self, 'zmq_manager') and self.zmq_manager:
1712 self.zmq_manager.cleanup()
1714 # ========== Plate View Methods ==========
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
1727 # Otherwise toggle in main layout
1728 self.plate_view_widget.setVisible(checked)
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")
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
1745 from PyQt6.QtWidgets import QDialog
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)
1754 # Create layout for window
1755 window_layout = QVBoxLayout(self.plate_view_detached_window)
1756 window_layout.setContentsMargins(10, 10, 10, 10)
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)
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)
1769 # Connect close event to reattach
1770 self.plate_view_detached_window.closeEvent = lambda event: self._on_detached_window_closed(event)
1772 # Show window
1773 self.plate_view_detached_window.show()
1775 logger.info("Plate view detached to external window")
1777 def _reattach_plate_view(self):
1778 """Reattach plate view to main layout."""
1779 if not self.plate_view_detached_window:
1780 return
1782 # Store reference before clearing
1783 window = self.plate_view_detached_window
1784 self.plate_view_detached_window = None
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())
1791 # Close and cleanup detached window
1792 window.close()
1793 window.deleteLater()
1795 logger.info("Plate view reattached to main window")
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
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())
1810 logger.info("Plate view reattached to main window (window closed)")
1812 event.accept()
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()
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
1831 # Detect plate dimensions and build coordinate mapping
1832 plate_dimensions = self._detect_plate_dimensions(well_ids) if well_ids else None
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
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)
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)
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
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
1870 def _detect_plate_subdirs(self, current_folder: Optional[str]) -> List[str]:
1871 """
1872 Detect plate output subdirectories.
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)
1878 Returns list of subdirectory names (not full paths).
1879 """
1880 if not self.orchestrator:
1881 return []
1883 plate_path = self.orchestrator.plate_path
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 []
1896 # Find immediate subdirectories that contain well files
1897 subdirs_with_wells = set()
1899 for filename in self.all_files.keys():
1900 file_path = Path(filename)
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
1907 # If file is in a subdirectory (not directly in base_path)
1908 if len(parts) > 1:
1909 subdir_name = parts[0]
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
1924 return sorted(list(subdirs_with_wells))
1926 # ========== Plate View Helper Methods ==========
1928 def _extract_well_id(self, metadata: dict) -> str:
1929 """
1930 Extract well ID from metadata.
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'])
1938 def _detect_plate_dimensions(self, well_ids: Set[str]) -> tuple[int, int]:
1939 """
1940 Auto-detect plate dimensions from well IDs.
1942 Uses existing infrastructure:
1943 - FilenameParser.extract_component_coordinates() to parse each well ID
1944 - Determines max row/col from parsed coordinates
1946 Returns (rows, cols) tuple.
1947 Raises ValueError if well IDs are invalid format.
1948 """
1949 parser = self.orchestrator.microscope_handler.parser
1951 rows = set()
1952 cols = set()
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))
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 ]
1967 return (max(row_indices), max(cols))
1969 def _update_status_threadsafe(self, message: str):
1970 """Update status label from any thread (thread-safe).
1972 Args:
1973 message: Status message to display
1974 """
1975 self._status_update_signal.emit(message)
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)