senoquant 1.0.0b3__py3-none-any.whl → 1.0.0b4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1113 @@
1
+ """Frontend widget for the Visualization tab."""
2
+
3
+ from dataclasses import dataclass
4
+ import json
5
+ from pathlib import Path
6
+ import pandas as pd
7
+ import shutil
8
+ from qtpy.QtCore import Qt, QTimer
9
+ from qtpy.QtGui import QGuiApplication, QPixmap
10
+ from qtpy.QtWidgets import (
11
+ QComboBox,
12
+ QFileDialog,
13
+ QFormLayout,
14
+ QGroupBox,
15
+ QFrame,
16
+ QHeaderView,
17
+ QHBoxLayout,
18
+ QLabel,
19
+ QLineEdit,
20
+ QPushButton,
21
+ QScrollArea,
22
+ QSizePolicy,
23
+ QTableWidget,
24
+ QTableWidgetItem,
25
+ QVBoxLayout,
26
+ QWidget,
27
+ )
28
+
29
+ from .backend import VisualizationBackend
30
+ from .plots import PlotConfig, build_feature_data, get_feature_registry
31
+ from .plots.base import RefreshingComboBox
32
+
33
+
34
+ @dataclass
35
+ class PlotUIContext:
36
+ """UI context for a single plot row."""
37
+
38
+ state: PlotConfig
39
+ section: QGroupBox
40
+ type_combo: QComboBox
41
+ left_dynamic_layout: QVBoxLayout
42
+ left_layout: QVBoxLayout
43
+ right_layout: QVBoxLayout
44
+ plot_handler: object | None = None
45
+
46
+
47
+ class VisualizationTab(QWidget):
48
+ """Visualization tab UI for configuring plot generation.
49
+
50
+ Parameters
51
+ ----------
52
+ backend : VisualizationBackend or None
53
+ Backend instance for visualization workflows.
54
+ napari_viewer : object or None
55
+ Napari viewer used to populate layer dropdowns.
56
+ """
57
+ def __init__(
58
+ self,
59
+ backend: VisualizationBackend | None = None,
60
+ napari_viewer=None,
61
+ *,
62
+ show_output_section: bool = True,
63
+ show_process_button: bool = True,
64
+ # enable_rois: bool = True,
65
+ show_right_column: bool = True,
66
+ enable_thresholds: bool = True,
67
+ ) -> None:
68
+ """Initialize the visualization tab UI.
69
+
70
+ Parameters
71
+ ----------
72
+ backend : VisualizationBackend or None
73
+ Backend instance for visualization workflows.
74
+ napari_viewer : object or None
75
+ Napari viewer used to populate layer dropdowns.
76
+ show_output_section : bool, optional
77
+ Whether to show the output configuration controls.
78
+ show_process_button : bool, optional
79
+ Whether to show the process button.
80
+ # enable_rois : bool, optional
81
+ # Whether to show ROI configuration controls within features.
82
+ show_right_column : bool, optional
83
+ Whether to show the right-hand feature column.
84
+ enable_thresholds : bool, optional
85
+ Whether to show threshold controls within features.
86
+ """
87
+ super().__init__()
88
+ self._backend = backend or VisualizationBackend()
89
+ self._viewer = napari_viewer
90
+ # self._enable_rois = enable_rois
91
+ self._show_right_column = show_right_column
92
+ self._enable_thresholds = enable_thresholds
93
+ self._feature_configs: list[PlotUIContext] = []
94
+ self._feature_registry = get_feature_registry()
95
+ self._features_watch_timer: QTimer | None = None
96
+ self._features_last_size: tuple[int, int] | None = None
97
+
98
+ layout = QVBoxLayout()
99
+
100
+ layout.addWidget(self._make_input_section())
101
+ layout.addWidget(self._make_marker_section())
102
+ layout.addWidget(self._make_plots_section())
103
+
104
+ # Add plot display area
105
+ layout.addWidget(self._make_plot_display_section(show_process_button))
106
+
107
+ if show_output_section:
108
+ layout.addWidget(self._make_output_section())
109
+
110
+ layout.addStretch(1)
111
+ self.setLayout(layout)
112
+
113
+ def _make_input_section(self) -> QGroupBox:
114
+ """Build the input configuration section."""
115
+ section = QGroupBox("Input")
116
+ section_layout = QVBoxLayout()
117
+ form_layout = QFormLayout()
118
+ form_layout.setFieldGrowthPolicy(QFormLayout.AllNonFixedFieldsGrow)
119
+
120
+ self._input_path = QLineEdit()
121
+ self._input_path.setPlaceholderText("Folder with quantification files")
122
+ browse_button = QPushButton("Browse")
123
+ browse_button.clicked.connect(self._select_input_path)
124
+ input_row = QHBoxLayout()
125
+ input_row.setContentsMargins(0, 0, 0, 0)
126
+ input_row.addWidget(self._input_path)
127
+ input_row.addWidget(browse_button)
128
+ input_widget = QWidget()
129
+ input_widget.setLayout(input_row)
130
+ self._input_path.textChanged.connect(self._on_input_path_changed)
131
+
132
+ self._extensions = QLineEdit()
133
+ self._extensions.setText(".csv, .xlsx, .xls")
134
+
135
+ form_layout.addRow("Input folder", input_widget)
136
+ form_layout.addRow("Extensions", self._extensions)
137
+
138
+ section_layout.addLayout(form_layout)
139
+ section.setLayout(section_layout)
140
+ return section
141
+
142
+ def _make_marker_section(self) -> QGroupBox:
143
+ """Build the marker selection and thresholding section."""
144
+ section = QGroupBox("Marker selection && thresholding")
145
+ layout = QVBoxLayout()
146
+
147
+ # Add Select/Deselect buttons
148
+ btn_layout = QHBoxLayout()
149
+ btn_layout.setContentsMargins(0, 8, 0, 5)
150
+ sel_all = QPushButton("Select All")
151
+ sel_all.clicked.connect(self._select_all_markers)
152
+ desel_all = QPushButton("Deselect All")
153
+ desel_all.clicked.connect(self._deselect_all_markers)
154
+ btn_layout.addWidget(sel_all)
155
+ btn_layout.addWidget(desel_all)
156
+ btn_layout.addStretch()
157
+ layout.addLayout(btn_layout)
158
+
159
+ self._marker_table = QTableWidget()
160
+ self._marker_table.setColumnCount(3)
161
+ self._marker_table.setHorizontalHeaderLabels(["Include", "Marker", "Threshold"])
162
+
163
+ header = self._marker_table.horizontalHeader()
164
+ header.setSectionResizeMode(0, QHeaderView.ResizeToContents)
165
+ header.setSectionResizeMode(1, QHeaderView.Stretch)
166
+ header.setSectionResizeMode(2, QHeaderView.ResizeToContents)
167
+
168
+ # Hide vertical header
169
+ self._marker_table.verticalHeader().setVisible(False)
170
+
171
+ layout.addWidget(self._marker_table)
172
+ section.setLayout(layout)
173
+ return section
174
+
175
+ def _on_input_path_changed(self, path_text: str) -> None:
176
+ """Handle input path changes to populate markers."""
177
+ path = Path(path_text)
178
+ if not path.exists() or not path.is_dir():
179
+ return
180
+
181
+ # Find first CSV or Excel file
182
+ data_file = None
183
+ for ext in [".csv", ".xlsx", ".xls"]:
184
+ found = list(path.glob(f"*{ext}"))
185
+ if found:
186
+ data_file = found[0]
187
+ break
188
+
189
+ if data_file:
190
+ self._populate_markers_from_file(data_file)
191
+
192
+ # Look for JSON thresholds
193
+ json_files = list(path.glob("*.json"))
194
+ target_json = None
195
+ if json_files:
196
+ # Prioritize files with 'threshold' in the name
197
+ for jf in json_files:
198
+ if "threshold" in jf.name.lower():
199
+ target_json = jf
200
+ break
201
+ if not target_json:
202
+ target_json = json_files[0]
203
+ self._load_thresholds_from_json(target_json)
204
+
205
+ def _populate_markers_from_file(self, file_path: Path) -> None:
206
+ """Read header from file and populate marker table."""
207
+ try:
208
+ if file_path.suffix == ".csv":
209
+ df = pd.read_csv(file_path, nrows=0)
210
+ else:
211
+ df = pd.read_excel(file_path, nrows=0)
212
+
213
+ markers = set()
214
+ for col in df.columns:
215
+ if "_mean_intensity" in col:
216
+ # Extract marker name (first part)
217
+ marker = col.split("_mean_intensity")[0]
218
+ markers.add(marker)
219
+
220
+ self._marker_table.setRowCount(0)
221
+ for i, marker in enumerate(sorted(markers)):
222
+ self._marker_table.insertRow(i)
223
+
224
+ # Checkbox
225
+ chk_item = QTableWidgetItem()
226
+ chk_item.setCheckState(Qt.Checked)
227
+ chk_item.setFlags(Qt.ItemIsUserCheckable | Qt.ItemIsEnabled)
228
+ self._marker_table.setItem(i, 0, chk_item)
229
+
230
+ # Marker Name
231
+ name_item = QTableWidgetItem(marker)
232
+ name_item.setFlags(Qt.ItemIsEnabled)
233
+ self._marker_table.setItem(i, 1, name_item)
234
+
235
+ # Threshold Input
236
+ thresh_input = QLineEdit()
237
+ thresh_input.setPlaceholderText("Auto")
238
+ self._marker_table.setCellWidget(i, 2, thresh_input)
239
+
240
+ except Exception as e:
241
+ print(f"Error populating markers: {e}")
242
+
243
+ def _load_thresholds_from_json(self, json_path: Path) -> None:
244
+ """Load thresholds from a JSON file."""
245
+ try:
246
+ print(f"[Frontend] Loading thresholds from {json_path}")
247
+ with open(json_path, "r") as f:
248
+ data = json.load(f)
249
+
250
+ thresholds_map = {}
251
+
252
+ # Handle SenoQuant export format (dict with "channels" list)
253
+ if isinstance(data, dict) and "channels" in data and isinstance(data["channels"], list):
254
+ for ch in data["channels"]:
255
+ name = ch.get("name") or ch.get("channel")
256
+ if not name:
257
+ continue
258
+
259
+ # Replicate sanitization to match CSV headers
260
+ safe_name = "".join(
261
+ c if c.isalnum() or c in "-_ " else "_" for c in name
262
+ ).strip().replace(" ", "_").lower()
263
+
264
+ # Prefer threshold_min
265
+ val = ch.get("threshold_min")
266
+ if val is None:
267
+ val = ch.get("threshold")
268
+
269
+ if val is not None:
270
+ thresholds_map[safe_name] = val
271
+ thresholds_map[name] = val
272
+
273
+ # Handle simple key-value format
274
+ elif isinstance(data, dict):
275
+ thresholds_map = data
276
+
277
+ # Iterate over table rows and set thresholds if found
278
+ for row in range(self._marker_table.rowCount()):
279
+ marker_item = self._marker_table.item(row, 1)
280
+ if not marker_item:
281
+ continue
282
+ marker = marker_item.text()
283
+
284
+ val = None
285
+ if marker in thresholds_map:
286
+ val = thresholds_map[marker]
287
+ elif f"{marker}_mean_intensity" in thresholds_map:
288
+ val = thresholds_map[f"{marker}_mean_intensity"]
289
+
290
+ if val is not None:
291
+ widget = self._marker_table.cellWidget(row, 2)
292
+ if isinstance(widget, QLineEdit):
293
+ widget.setText(str(val))
294
+ except Exception as e:
295
+ print(f"Error loading thresholds from JSON: {e}")
296
+
297
+ def _select_all_markers(self) -> None:
298
+ """Select all markers in the table."""
299
+ for row in range(self._marker_table.rowCount()):
300
+ item = self._marker_table.item(row, 0)
301
+ if item:
302
+ item.setCheckState(Qt.Checked)
303
+
304
+ def _deselect_all_markers(self) -> None:
305
+ """Deselect all markers in the table."""
306
+ for row in range(self._marker_table.rowCount()):
307
+ item = self._marker_table.item(row, 0)
308
+ if item:
309
+ item.setCheckState(Qt.Unchecked)
310
+
311
+ def _get_marker_settings(self) -> tuple[list[str], dict[str, float]]:
312
+ """Retrieve selected markers and their thresholds from the table."""
313
+ selected_markers = []
314
+ thresholds = {}
315
+
316
+ for row in range(self._marker_table.rowCount()):
317
+ # Check if selected
318
+ chk_item = self._marker_table.item(row, 0)
319
+ if not chk_item or chk_item.checkState() != Qt.Checked:
320
+ continue
321
+
322
+ # Get marker name
323
+ name_item = self._marker_table.item(row, 1)
324
+ if not name_item:
325
+ continue
326
+ marker = name_item.text()
327
+ selected_markers.append(marker)
328
+
329
+ # Get threshold
330
+ thresh_widget = self._marker_table.cellWidget(row, 2)
331
+ if isinstance(thresh_widget, QLineEdit):
332
+ text = thresh_widget.text().strip()
333
+ if text:
334
+ try:
335
+ val = float(text)
336
+ thresholds[marker] = val
337
+ except ValueError:
338
+ pass # Ignore invalid numbers
339
+
340
+ return selected_markers, thresholds
341
+
342
+ def _make_output_section(self) -> QGroupBox:
343
+ """Build the output configuration section.
344
+
345
+ Returns
346
+ -------
347
+ QGroupBox
348
+ Group box containing output settings.
349
+ """
350
+ section = QGroupBox("Output")
351
+ section_layout = QVBoxLayout()
352
+
353
+ form_layout = QFormLayout()
354
+ form_layout.setFieldGrowthPolicy(QFormLayout.AllNonFixedFieldsGrow)
355
+
356
+ self._output_path_input = QLineEdit()
357
+ default_output = str(Path.cwd())
358
+ self._output_path_input.setText(default_output)
359
+ self._output_path_input.setPlaceholderText("Output folder path")
360
+ browse_button = QPushButton("Browse")
361
+ browse_button.clicked.connect(self._select_output_path)
362
+ output_path_row = QHBoxLayout()
363
+ output_path_row.setContentsMargins(0, 0, 0, 0)
364
+ output_path_row.addWidget(self._output_path_input)
365
+ output_path_row.addWidget(browse_button)
366
+ output_path_widget = QWidget()
367
+ output_path_widget.setLayout(output_path_row)
368
+
369
+ self._save_name_input = QLineEdit()
370
+ self._save_name_input.setPlaceholderText("Plot name")
371
+ self._save_name_input.setMinimumWidth(180)
372
+ self._save_name_input.setSizePolicy(
373
+ QSizePolicy.Expanding, QSizePolicy.Fixed
374
+ )
375
+
376
+ self._format_combo = QComboBox()
377
+ self._format_combo.addItems(["png", "svg", "pdf"])
378
+ self._configure_combo(self._format_combo)
379
+
380
+ form_layout.addRow("Output path", output_path_widget)
381
+ form_layout.addRow("Plot name", self._save_name_input)
382
+ form_layout.addRow("Format", self._format_combo)
383
+
384
+ section_layout.addLayout(form_layout)
385
+
386
+ # Add Save button
387
+ save_button = QPushButton("Save Plot")
388
+ save_button.clicked.connect(self._save_plots)
389
+ section_layout.addWidget(save_button)
390
+ self._save_button = save_button
391
+
392
+ section.setLayout(section_layout)
393
+ return section
394
+
395
+ def _make_plot_display_section(self, show_process_button: bool = True) -> QGroupBox:
396
+ """Build the plot display section.
397
+
398
+ Parameters
399
+ ----------
400
+ show_process_button : bool, optional
401
+ Whether to show the Process button.
402
+
403
+ Returns
404
+ -------
405
+ QGroupBox
406
+ Group box containing generated plot images and process button.
407
+ """
408
+ section = QGroupBox("Plot Preview")
409
+ section_layout = QVBoxLayout()
410
+
411
+ # Create a resizable widget for displaying plots (no scrolling)
412
+ self._plot_display_widget = QWidget()
413
+ self._plot_display_widget.setMinimumHeight(300)
414
+ self._plot_display_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
415
+ self._plot_display_layout = QVBoxLayout()
416
+ self._plot_display_layout.setContentsMargins(0, 0, 0, 0)
417
+ self._plot_display_layout.setSpacing(6)
418
+ self._plot_display_widget.setLayout(self._plot_display_layout)
419
+ section_layout.addWidget(self._plot_display_widget)
420
+
421
+ # Add Process button
422
+ if show_process_button:
423
+ process_button = QPushButton("Process")
424
+ process_button.clicked.connect(self._process_features)
425
+ section_layout.addWidget(process_button)
426
+ self._process_button = process_button
427
+
428
+ section.setLayout(section_layout)
429
+ return section
430
+
431
+
432
+ def _make_plots_section(self) -> QGroupBox:
433
+ """Build the plots configuration section.
434
+
435
+ Returns
436
+ -------
437
+ QGroupBox
438
+ Group box containing plot inputs.
439
+ """
440
+ section = QGroupBox("Plots")
441
+ section.setFlat(True)
442
+ section.setStyleSheet(
443
+ "QGroupBox {"
444
+ " margin-top: 8px;"
445
+ "}"
446
+ "QGroupBox::title {"
447
+ " subcontrol-origin: margin;"
448
+ " subcontrol-position: top left;"
449
+ " padding: 0 6px;"
450
+ "}"
451
+ )
452
+
453
+ frame = QFrame()
454
+ frame.setFrameShape(QFrame.StyledPanel)
455
+ frame.setFrameShadow(QFrame.Plain)
456
+ frame.setObjectName("features-section-frame")
457
+ frame.setStyleSheet(
458
+ "QFrame#features-section-frame {"
459
+ " border: 1px solid palette(mid);"
460
+ " border-radius: 4px;"
461
+ "}"
462
+ )
463
+
464
+ scroll_area = QScrollArea()
465
+ scroll_area.setWidgetResizable(True)
466
+ scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded)
467
+ scroll_area.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
468
+ self._features_scroll_area = scroll_area
469
+
470
+ features_container = QWidget()
471
+ self._features_container = features_container
472
+ features_container.setSizePolicy(
473
+ QSizePolicy.Expanding, QSizePolicy.Minimum
474
+ )
475
+ features_container.setMinimumWidth(200)
476
+ self._features_min_width = 200
477
+ self._features_layout = QVBoxLayout()
478
+ self._features_layout.setContentsMargins(0, 0, 0, 0)
479
+ self._features_layout.setSpacing(8)
480
+ self._features_layout.setSizeConstraint(QVBoxLayout.SetMinAndMaxSize)
481
+ features_container.setLayout(self._features_layout)
482
+ scroll_area.setWidget(features_container)
483
+
484
+ frame_layout = QVBoxLayout()
485
+ frame_layout.setContentsMargins(10, 12, 10, 10)
486
+ frame_layout.addWidget(scroll_area)
487
+ frame.setLayout(frame_layout)
488
+
489
+ section_layout = QVBoxLayout()
490
+ section_layout.setContentsMargins(8, 12, 8, 4)
491
+ section_layout.addWidget(frame)
492
+
493
+ section.setLayout(section_layout)
494
+
495
+ self._add_feature_row()
496
+ self._apply_features_layout()
497
+ self._start_features_watch()
498
+ return section
499
+
500
+ def showEvent(self, event) -> None:
501
+ """Ensure layout sizing is applied on initial show.
502
+
503
+ Parameters
504
+ ----------
505
+ event : QShowEvent
506
+ Qt show event passed by the widget.
507
+ """
508
+ super().showEvent(event)
509
+ self._apply_features_layout()
510
+
511
+ def resizeEvent(self, event) -> None:
512
+ """Resize handler to keep the features list at a capped height.
513
+
514
+ Parameters
515
+ ----------
516
+ event : QResizeEvent
517
+ Qt resize event passed by the widget.
518
+ """
519
+ super().resizeEvent(event)
520
+ self._apply_features_layout()
521
+ # Rescale any preview images to fit the new size
522
+ try:
523
+ self._rescale_all_plot_labels()
524
+ except Exception:
525
+ pass
526
+
527
+ def _add_feature_row(self, state: PlotConfig | None = None) -> None:
528
+ """Add a new feature input row."""
529
+ if isinstance(state, bool):
530
+ state = None
531
+
532
+ section_layout = QVBoxLayout()
533
+
534
+ content_layout = QHBoxLayout()
535
+ content_layout.setContentsMargins(0, 0, 0, 0)
536
+ content_layout.setSpacing(12)
537
+ content_layout.setAlignment(Qt.AlignTop)
538
+ left_layout = QVBoxLayout()
539
+ left_layout.setContentsMargins(0, 0, 0, 0)
540
+ left_layout.setSpacing(6)
541
+ right_layout = QVBoxLayout()
542
+ right_layout.setContentsMargins(0, 0, 0, 0)
543
+ right_layout.setSpacing(6)
544
+
545
+ form_layout = QFormLayout()
546
+ form_layout.setFieldGrowthPolicy(QFormLayout.AllNonFixedFieldsGrow)
547
+
548
+ type_combo = RefreshingComboBox(
549
+ refresh_callback=self._notify_features_changed
550
+ )
551
+ feature_types = self._feature_types()
552
+ type_combo.addItems(feature_types)
553
+ self._configure_combo(type_combo)
554
+
555
+ form_layout.addRow("Plot Type", type_combo)
556
+ left_layout.addLayout(form_layout)
557
+
558
+ left_dynamic_container = QWidget()
559
+ left_dynamic_container.setSizePolicy(
560
+ QSizePolicy.Expanding, QSizePolicy.Fixed
561
+ )
562
+ left_dynamic_layout = QVBoxLayout()
563
+ left_dynamic_layout.setContentsMargins(0, 0, 0, 0)
564
+ left_dynamic_layout.setSpacing(6)
565
+ left_dynamic_container.setLayout(left_dynamic_layout)
566
+ left_layout.addWidget(left_dynamic_container)
567
+
568
+ left_container = QWidget()
569
+ left_container.setLayout(left_layout)
570
+ left_container.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
571
+
572
+ right_container = QWidget()
573
+ right_container.setLayout(right_layout)
574
+ right_container.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
575
+
576
+ self._left_container = left_container
577
+ self._right_container = right_container
578
+
579
+ content_layout.addWidget(left_container, 3)
580
+ if self._show_right_column:
581
+ content_layout.addWidget(right_container, 2)
582
+ section_layout.addLayout(content_layout)
583
+ self._apply_features_layout()
584
+
585
+ # Determine feature type first
586
+ feature_type = (
587
+ state.type_name
588
+ if state is not None and state.type_name
589
+ else type_combo.currentText()
590
+ )
591
+ if state is None:
592
+ state = PlotConfig(
593
+ type_name=feature_type,
594
+ data=build_feature_data(feature_type),
595
+ )
596
+ if feature_type in feature_types:
597
+ type_combo.blockSignals(True)
598
+ type_combo.setCurrentText(feature_type)
599
+ type_combo.blockSignals(False)
600
+
601
+ # Create feature section with feature type as title
602
+ feature_section = QGroupBox()
603
+ feature_section.setFlat(True)
604
+ feature_section.setStyleSheet(
605
+ "QGroupBox {"
606
+ " margin-top: 6px;"
607
+ "}"
608
+ "QGroupBox::title {"
609
+ " subcontrol-origin: margin;"
610
+ " subcontrol-position: top left;"
611
+ " padding: 0 6px;"
612
+ "}"
613
+ )
614
+ feature_section.setLayout(section_layout)
615
+ feature_section.setSizePolicy(
616
+ QSizePolicy.Expanding, QSizePolicy.Fixed
617
+ )
618
+
619
+ self._features_layout.addWidget(feature_section)
620
+ context = PlotUIContext(
621
+ state=state,
622
+ section=feature_section,
623
+ type_combo=type_combo,
624
+ left_dynamic_layout=left_dynamic_layout,
625
+ left_layout=left_layout,
626
+ right_layout=right_layout,
627
+ )
628
+ self._feature_configs.append(context)
629
+ type_combo.currentTextChanged.connect(
630
+ lambda _text, ctx=context: self._on_feature_type_changed(ctx)
631
+ )
632
+ self._build_feature_handler(context, preserve_data=True)
633
+ self._notify_features_changed()
634
+ self._features_layout.activate()
635
+ QTimer.singleShot(0, self._apply_features_layout)
636
+
637
+ def _on_feature_type_changed(self, context: PlotUIContext) -> None:
638
+ """Update a plot section when its type changes.
639
+
640
+ Parameters
641
+ ----------
642
+ context : PlotUIContext
643
+ Plot UI context and data.
644
+ """
645
+ self._build_feature_handler(context, preserve_data=False)
646
+
647
+ def _build_feature_handler(
648
+ self,
649
+ context: PlotUIContext,
650
+ *,
651
+ preserve_data: bool,
652
+ ) -> None:
653
+ left_dynamic_layout = context.left_dynamic_layout
654
+ self._clear_layout(left_dynamic_layout)
655
+ self._clear_layout(context.right_layout)
656
+ feature_type = context.type_combo.currentText()
657
+ context.state.type_name = feature_type
658
+ if not preserve_data:
659
+ context.state.data = build_feature_data(feature_type)
660
+
661
+ feature_handler = self._feature_handler_for_type(feature_type, context)
662
+ print(f"[Frontend] Built handler for {feature_type}: {feature_handler}")
663
+ context.plot_handler = feature_handler
664
+ if feature_handler is not None:
665
+ feature_handler.build()
666
+ print(f"[Frontend] Handler build() called")
667
+ else:
668
+ print(f"[Frontend] Handler is None!")
669
+ self._notify_features_changed()
670
+
671
+
672
+ def _notify_features_changed(self) -> None:
673
+ """Notify plot handlers that the plot list has changed."""
674
+ for feature_cls in self._feature_registry.values():
675
+ feature_cls.update_type_options(self, self._feature_configs)
676
+ for context in self._feature_configs:
677
+ handler = context.plot_handler
678
+ if handler is not None:
679
+ handler.on_features_changed(self._feature_configs)
680
+ # Update default plot name shown in the output section
681
+ self._update_default_plot_name()
682
+
683
+ def _update_default_plot_name(self) -> None:
684
+ """Compute and set a sensible default for the Plot name field.
685
+
686
+ Uses the joined feature type names separated by hyphens. Only sets
687
+ the field when the user has not provided a custom name (empty) or
688
+ when the current value matches the previous auto-generated value.
689
+ """
690
+ try:
691
+ names = [ctx.state.type_name for ctx in self._feature_configs if getattr(ctx, 'state', None)]
692
+ if not names:
693
+ auto = "visualization"
694
+ else:
695
+ auto = "-".join(names)
696
+ current = self._save_name_input.text().strip() if hasattr(self, '_save_name_input') else ''
697
+ prev_auto = getattr(self, '_plot_name_auto', '')
698
+ if not current or current == prev_auto:
699
+ self._save_name_input.setText(auto)
700
+ self._plot_name_auto = auto
701
+ except Exception:
702
+ # Fail silently; this is only a nicety
703
+ pass
704
+
705
+ def _feature_types(self) -> list[str]:
706
+ """Return the available feature type names."""
707
+ return list(self._feature_registry.keys())
708
+
709
+ def load_feature_configs(self, configs: list[PlotConfig]) -> None:
710
+ """Replace the current plot list with provided configs."""
711
+ for context in list(self._feature_configs):
712
+ self._remove_feature(context.section)
713
+ if not configs:
714
+ self._add_feature_row()
715
+ return
716
+ for config in configs:
717
+ self._add_feature_row(config)
718
+
719
+ def _select_input_path(self) -> None:
720
+ """Open a folder picker for the input path."""
721
+ path = QFileDialog.getExistingDirectory(self, "Select input folder")
722
+ if path:
723
+ self._input_path.setText(path)
724
+
725
+ def _select_output_path(self) -> None:
726
+ """Open a folder selection dialog for the output path."""
727
+ path = QFileDialog.getExistingDirectory(
728
+ self,
729
+ "Select output folder",
730
+ self._output_path_input.text(),
731
+ )
732
+ if path:
733
+ self._output_path_input.setText(path)
734
+
735
+ def _process_features(self) -> None:
736
+ """Trigger visualization processing for configured plots."""
737
+ # Clear previous plots
738
+ while self._plot_display_layout.count():
739
+ child = self._plot_display_layout.takeAt(0)
740
+ if child.widget():
741
+ child.widget().deleteLater()
742
+
743
+ print(f"[Frontend] Processing {len(self._feature_configs)} plot configs")
744
+ for i, cfg in enumerate(self._feature_configs):
745
+ print(f"[Frontend] Config {i}: type={cfg.state.type_name}, handler={cfg.plot_handler}")
746
+
747
+ # Clean up previous result temp files if they exist
748
+ if hasattr(self, "_last_visualization_result") and self._last_visualization_result:
749
+ try:
750
+ shutil.rmtree(self._last_visualization_result.temp_root, ignore_errors=True)
751
+ except Exception as e:
752
+ print(f"[Frontend] Warning: Failed to cleanup previous temp dir: {e}")
753
+
754
+ markers, thresholds = self._get_marker_settings()
755
+
756
+ process = getattr(self._backend, "process", None)
757
+ if callable(process):
758
+ input_path = Path(self._input_path.text())
759
+ result = process(
760
+ self._feature_configs,
761
+ input_path,
762
+ self._output_path_input.text(),
763
+ self._save_name_input.text(),
764
+ self._format_combo.currentText(),
765
+ markers=markers,
766
+ thresholds=thresholds,
767
+ save=False,
768
+ cleanup=False,
769
+ )
770
+
771
+ # Store result for later saving
772
+ self._last_visualization_result = result
773
+
774
+ print(f"[Frontend] Process returned result: {result}")
775
+ print(f"[Frontend] Output root: {result.output_root if result else 'None'}")
776
+
777
+ # Display generated plots using the backend-returned final paths
778
+ if result and hasattr(result, "plot_outputs"):
779
+ print(f"[Frontend] Found {len(result.plot_outputs)} plot outputs")
780
+ for plot_output in result.plot_outputs:
781
+ for output_file in getattr(plot_output, "outputs", []):
782
+ try:
783
+ output_file = Path(output_file)
784
+ except Exception:
785
+ output_file = None
786
+ if output_file and output_file.exists() and output_file.suffix.lower() in [".png", ".svg", ".pdf"]:
787
+ print(f"[Frontend] Displaying: {output_file}")
788
+ self._display_plot_file(output_file)
789
+ else:
790
+ print(f"[Frontend] Skipping non-existent or unsupported file: {output_file}")
791
+
792
+ def _plot_dir_name(self, plot_output: object) -> str:
793
+ """Build filesystem-friendly folder name for a plot (matches backend)."""
794
+ plot_type = getattr(plot_output, "plot_type", "unknown")
795
+ name = plot_type.strip()
796
+ safe = "".join(
797
+ c if c.isalnum() or c in " -_" else "_" for c in name
798
+ )
799
+ return safe
800
+
801
+ def _save_plots(self) -> None:
802
+ """Save the current plot results to the output directory."""
803
+ if not hasattr(self, "_last_visualization_result") or self._last_visualization_result is None:
804
+ print("No plots to save. Run Process first.")
805
+ return
806
+
807
+ result = self._last_visualization_result
808
+ output_root = result.output_root
809
+
810
+ # Perform the save using the backend
811
+ if hasattr(self._backend, "save_result"):
812
+ self._backend.save_result(
813
+ result,
814
+ self._output_path_input.text(),
815
+ self._save_name_input.text()
816
+ )
817
+
818
+ saved_files: list[str] = []
819
+ for plot_output in getattr(result, "plot_outputs", []):
820
+ for p in getattr(plot_output, "outputs", []):
821
+ try:
822
+ path = Path(p)
823
+ except Exception:
824
+ continue
825
+ if path.exists():
826
+ saved_files.append(str(path))
827
+
828
+ if saved_files:
829
+ print(f"Plots saved to: {output_root}")
830
+ for f in saved_files:
831
+ print(f" - {f}")
832
+ else:
833
+ # No files present: re-run process to force saving
834
+ result = process(
835
+ self._feature._input_path.text(),
836
+ self._output_path_input.text(),
837
+ self._save_na._format_combo.currentText(),
838
+ markers=markers,
839
+ thresholds=thresholds,
840
+ save=True,
841
+ cleanup=True,
842
+ )
843
+ self._last_visualization_result = result
844
+ print(f"Re-run complete. Check folder: {self._output_path_input.text() or Path.cwd()}")
845
+
846
+ def _feature_handler_for_type(
847
+ self, feature_type: str, context: PlotUIContext
848
+ ):
849
+ """Return the feature handler for a given feature type.
850
+
851
+ Parameters
852
+ ----------
853
+ feature_type : str
854
+ Selected feature type.
855
+ config : dict
856
+ Feature configuration dictionary.
857
+
858
+ Returns
859
+ -------
860
+ SenoQuantFeature or None
861
+ Feature handler instance for the selected type.
862
+ """
863
+ feature_cls = self._feature_registry.get(feature_type)
864
+ if feature_cls is None:
865
+ return None
866
+ return feature_cls(self, context)
867
+
868
+ def _display_plot_file(self, file_path) -> None:
869
+ """Display a plot image file in the preview area.
870
+
871
+ Parameters
872
+ ----------
873
+ file_path : Path or str
874
+ Path to the plot image file (PNG or SVG).
875
+ """
876
+ from pathlib import Path
877
+ file_path = Path(file_path)
878
+
879
+ if file_path.suffix.lower() == ".png":
880
+ # Display PNG directly and scale to fit preview widget
881
+ pixmap = QPixmap(str(file_path))
882
+ if not pixmap.isNull():
883
+ label = QLabel()
884
+ label.setAlignment(Qt.AlignCenter)
885
+ label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
886
+ # store original pixmap for later rescaling
887
+ label._orig_pixmap = pixmap
888
+ # scale now to current widget size
889
+ self._rescale_plot_label(label)
890
+ self._plot_display_layout.addWidget(label)
891
+ elif file_path.suffix.lower() == ".svg":
892
+ # For SVG, display filename with link
893
+ link_label = QLabel(f'<a href="file:///{file_path}">View {file_path.name}</a>')
894
+ link_label.setOpenExternalLinks(True)
895
+ self._plot_display_layout.addWidget(link_label)
896
+ elif file_path.suffix.lower() == ".pdf":
897
+ # For PDF, display filename with link
898
+ link_label = QLabel(f'<a href="file:///{file_path}">View {file_path.name}</a>')
899
+ link_label.setOpenExternalLinks(True)
900
+ self._plot_display_layout.addWidget(link_label)
901
+
902
+ def _rescale_plot_label(self, label: QLabel) -> None:
903
+ """Rescale a QLabel containing an original QPixmap to fit preview area."""
904
+ try:
905
+ orig = getattr(label, "_orig_pixmap", None)
906
+ if orig is None:
907
+ return
908
+ max_w = max(10, self._plot_display_widget.width() - 20)
909
+ max_h = max(10, self._plot_display_widget.height() - 20)
910
+ scaled = orig.scaled(max_w, max_h, Qt.KeepAspectRatio, Qt.SmoothTransformation)
911
+ label.setPixmap(scaled)
912
+ except Exception:
913
+ pass
914
+
915
+ def _rescale_all_plot_labels(self) -> None:
916
+ """Rescale all displayed plot labels to fit the preview area."""
917
+ for i in range(self._plot_display_layout.count()):
918
+ item = self._plot_display_layout.itemAt(i)
919
+ widget = item.widget() if item is not None else None
920
+ if isinstance(widget, QLabel) and hasattr(widget, "_orig_pixmap"):
921
+ self._rescale_plot_label(widget)
922
+
923
+ def _configure_combo(self, combo: QComboBox) -> None:
924
+ """Apply sizing defaults to combo boxes.
925
+
926
+ Parameters
927
+ ----------
928
+ combo : QComboBox
929
+ Combo box to configure.
930
+ """
931
+ combo.setSizeAdjustPolicy(
932
+ QComboBox.AdjustToMinimumContentsLengthWithIcon
933
+ )
934
+ combo.setMinimumContentsLength(8)
935
+ combo.setMinimumWidth(140)
936
+ combo.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
937
+
938
+ def _clear_layout(self, layout: QVBoxLayout) -> None:
939
+ """Remove all widgets and layouts from a layout.
940
+
941
+ Parameters
942
+ ----------
943
+ layout : QVBoxLayout
944
+ Layout to clear.
945
+ """
946
+ while layout.count():
947
+ item = layout.takeAt(0)
948
+ widget = item.widget()
949
+ if widget is not None:
950
+ widget.deleteLater()
951
+ child_layout = item.layout()
952
+ if child_layout is not None:
953
+ self._clear_layout(child_layout)
954
+
955
+ def _feature_index(self, context: PlotUIContext) -> int:
956
+ """Return the 0-based index for a plot config.
957
+
958
+ Parameters
959
+ ----------
960
+ context : PlotUIContext
961
+ Plot UI context.
962
+
963
+ Returns
964
+ -------
965
+ int
966
+ 0-based index of the plot.
967
+ """
968
+ return self._feature_configs.index(context)
969
+ def _start_features_watch(self) -> None:
970
+ """Start a timer to monitor feature sizing changes.
971
+
972
+ The watcher polls for content size changes and reapplies layout
973
+ constraints without blocking the UI thread.
974
+ """
975
+ if self._features_watch_timer is not None:
976
+ return
977
+ self._features_watch_timer = QTimer(self)
978
+ self._features_watch_timer.setInterval(150)
979
+ self._features_watch_timer.timeout.connect(self._poll_features_geometry)
980
+ self._features_watch_timer.start()
981
+
982
+ def _poll_features_geometry(self) -> None:
983
+ """Recompute layout sizing when content size changes."""
984
+ if not hasattr(self, "_features_scroll_area"):
985
+ return
986
+ size = self._features_content_size()
987
+ if size == self._features_last_size:
988
+ return
989
+ self._features_last_size = size
990
+ self._apply_features_layout(size)
991
+
992
+ def _apply_features_layout(
993
+ self, content_size: tuple[int, int] | None = None
994
+ ) -> None:
995
+ """Apply sizing rules for the features container and scroll area.
996
+
997
+ Parameters
998
+ ----------
999
+ content_size : tuple of int or None
1000
+ Optional (width, height) of the features content. If None, the
1001
+ size is computed from the current layout.
1002
+ """
1003
+ if not hasattr(self, "_features_scroll_area"):
1004
+ return
1005
+ if content_size is None:
1006
+ content_size = self._features_content_size()
1007
+ content_width, content_height = content_size
1008
+
1009
+ total_min = getattr(self, "_features_min_width", 0)
1010
+ if total_min <= 0 and hasattr(self, "_features_container"):
1011
+ total_min = self._features_container.minimumWidth()
1012
+ left_hint = 0
1013
+ right_hint = 0
1014
+ if hasattr(self, "_left_container") and self._left_container is not None:
1015
+ try:
1016
+ left_hint = self._left_container.sizeHint().width()
1017
+ except RuntimeError:
1018
+ self._left_container = None
1019
+ if hasattr(self, "_right_container") and self._right_container is not None:
1020
+ try:
1021
+ right_hint = self._right_container.sizeHint().width()
1022
+ except RuntimeError:
1023
+ self._right_container = None
1024
+ left_min = max(int(total_min * 0.6), left_hint)
1025
+ right_min = max(int(total_min * 0.4), right_hint)
1026
+ if self._left_container is not None:
1027
+ try:
1028
+ self._left_container.setMinimumWidth(left_min)
1029
+ except RuntimeError:
1030
+ self._left_container = None
1031
+ if self._right_container is not None:
1032
+ try:
1033
+ self._right_container.setMinimumWidth(right_min)
1034
+ except RuntimeError:
1035
+ self._right_container = None
1036
+
1037
+ if hasattr(self, "_features_container"):
1038
+ self._features_container.setMinimumHeight(0)
1039
+ self._features_container.setMinimumWidth(
1040
+ max(total_min, content_width)
1041
+ )
1042
+ self._features_container.updateGeometry()
1043
+
1044
+ screen = self.window().screen() if self.window() is not None else None
1045
+ if screen is None:
1046
+ screen = QGuiApplication.primaryScreen()
1047
+ screen_height = screen.availableGeometry().height() if screen else 720
1048
+ target_height = max(180, int(screen_height * 0.5))
1049
+ frame = self._features_scroll_area.frameWidth() * 2
1050
+ scroll_slack = 2
1051
+ effective_height = content_height + scroll_slack
1052
+ height = max(0, min(target_height, effective_height + frame))
1053
+ self._features_scroll_area.setUpdatesEnabled(False)
1054
+ self._features_scroll_area.setFixedHeight(height)
1055
+ self._features_scroll_area.setUpdatesEnabled(True)
1056
+ self._features_scroll_area.updateGeometry()
1057
+ widget = self._features_scroll_area.widget()
1058
+ if widget is not None:
1059
+ widget.adjustSize()
1060
+ widget.updateGeometry()
1061
+ self._features_scroll_area.viewport().updateGeometry()
1062
+ bar = self._features_scroll_area.verticalScrollBar()
1063
+ if bar.maximum() > 0:
1064
+ self._features_scroll_area.setVerticalScrollBarPolicy(
1065
+ Qt.ScrollBarAsNeeded
1066
+ )
1067
+ else:
1068
+ self._features_scroll_area.setVerticalScrollBarPolicy(
1069
+ Qt.ScrollBarAlwaysOff
1070
+ )
1071
+ bar.setRange(0, 0)
1072
+ bar.setValue(0)
1073
+
1074
+ def _features_content_size(self) -> tuple[int, int]:
1075
+ """Compute the content size for the features layout.
1076
+
1077
+ Returns
1078
+ -------
1079
+ tuple of int
1080
+ (width, height) of the content.
1081
+ """
1082
+ if not hasattr(self, "_features_layout"):
1083
+ return (0, 0)
1084
+ layout = self._features_layout
1085
+ layout.activate()
1086
+ margins = layout.contentsMargins()
1087
+ spacing = layout.spacing()
1088
+ count = layout.count()
1089
+ total_height = margins.top() + margins.bottom()
1090
+ max_width = 0
1091
+ for index in range(count):
1092
+ item = layout.itemAt(index)
1093
+ widget = item.widget()
1094
+ if widget is None:
1095
+ item_size = item.sizeHint()
1096
+ else:
1097
+ widget.adjustSize()
1098
+ item_size = widget.sizeHint().expandedTo(
1099
+ widget.minimumSizeHint()
1100
+ )
1101
+ max_width = max(max_width, item_size.width())
1102
+ total_height += item_size.height()
1103
+ if count > 1:
1104
+ total_height += spacing * (count - 1)
1105
+ total_width = margins.left() + margins.right() + max_width
1106
+ if hasattr(self, "_features_container"):
1107
+ self._features_container.adjustSize()
1108
+ container_size = self._features_container.sizeHint().expandedTo(
1109
+ self._features_container.minimumSizeHint()
1110
+ )
1111
+ total_width = max(total_width, container_size.width())
1112
+ total_height = max(total_height, container_size.height())
1113
+ return (total_width, total_height)