setiastrosuitepro 1.6.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of setiastrosuitepro might be problematic. Click here for more details.

Files changed (174) hide show
  1. setiastro/__init__.py +2 -0
  2. setiastro/saspro/__init__.py +20 -0
  3. setiastro/saspro/__main__.py +784 -0
  4. setiastro/saspro/_generated/__init__.py +7 -0
  5. setiastro/saspro/_generated/build_info.py +2 -0
  6. setiastro/saspro/abe.py +1295 -0
  7. setiastro/saspro/abe_preset.py +196 -0
  8. setiastro/saspro/aberration_ai.py +694 -0
  9. setiastro/saspro/aberration_ai_preset.py +224 -0
  10. setiastro/saspro/accel_installer.py +218 -0
  11. setiastro/saspro/accel_workers.py +30 -0
  12. setiastro/saspro/add_stars.py +621 -0
  13. setiastro/saspro/astrobin_exporter.py +1007 -0
  14. setiastro/saspro/astrospike.py +153 -0
  15. setiastro/saspro/astrospike_python.py +1839 -0
  16. setiastro/saspro/autostretch.py +196 -0
  17. setiastro/saspro/backgroundneutral.py +560 -0
  18. setiastro/saspro/batch_convert.py +325 -0
  19. setiastro/saspro/batch_renamer.py +519 -0
  20. setiastro/saspro/blemish_blaster.py +488 -0
  21. setiastro/saspro/blink_comparator_pro.py +2923 -0
  22. setiastro/saspro/bundles.py +61 -0
  23. setiastro/saspro/bundles_dock.py +114 -0
  24. setiastro/saspro/cheat_sheet.py +168 -0
  25. setiastro/saspro/clahe.py +342 -0
  26. setiastro/saspro/comet_stacking.py +1377 -0
  27. setiastro/saspro/config.py +38 -0
  28. setiastro/saspro/config_bootstrap.py +40 -0
  29. setiastro/saspro/config_manager.py +316 -0
  30. setiastro/saspro/continuum_subtract.py +1617 -0
  31. setiastro/saspro/convo.py +1397 -0
  32. setiastro/saspro/convo_preset.py +414 -0
  33. setiastro/saspro/copyastro.py +187 -0
  34. setiastro/saspro/cosmicclarity.py +1564 -0
  35. setiastro/saspro/cosmicclarity_preset.py +407 -0
  36. setiastro/saspro/crop_dialog_pro.py +948 -0
  37. setiastro/saspro/crop_preset.py +189 -0
  38. setiastro/saspro/curve_editor_pro.py +2544 -0
  39. setiastro/saspro/curves_preset.py +375 -0
  40. setiastro/saspro/debayer.py +670 -0
  41. setiastro/saspro/debug_utils.py +29 -0
  42. setiastro/saspro/dnd_mime.py +35 -0
  43. setiastro/saspro/doc_manager.py +2634 -0
  44. setiastro/saspro/exoplanet_detector.py +2166 -0
  45. setiastro/saspro/file_utils.py +284 -0
  46. setiastro/saspro/fitsmodifier.py +744 -0
  47. setiastro/saspro/free_torch_memory.py +48 -0
  48. setiastro/saspro/frequency_separation.py +1343 -0
  49. setiastro/saspro/function_bundle.py +1594 -0
  50. setiastro/saspro/ghs_dialog_pro.py +660 -0
  51. setiastro/saspro/ghs_preset.py +284 -0
  52. setiastro/saspro/graxpert.py +634 -0
  53. setiastro/saspro/graxpert_preset.py +287 -0
  54. setiastro/saspro/gui/__init__.py +0 -0
  55. setiastro/saspro/gui/main_window.py +8494 -0
  56. setiastro/saspro/gui/mixins/__init__.py +33 -0
  57. setiastro/saspro/gui/mixins/dock_mixin.py +263 -0
  58. setiastro/saspro/gui/mixins/file_mixin.py +445 -0
  59. setiastro/saspro/gui/mixins/geometry_mixin.py +403 -0
  60. setiastro/saspro/gui/mixins/header_mixin.py +441 -0
  61. setiastro/saspro/gui/mixins/mask_mixin.py +421 -0
  62. setiastro/saspro/gui/mixins/menu_mixin.py +361 -0
  63. setiastro/saspro/gui/mixins/theme_mixin.py +367 -0
  64. setiastro/saspro/gui/mixins/toolbar_mixin.py +1324 -0
  65. setiastro/saspro/gui/mixins/update_mixin.py +309 -0
  66. setiastro/saspro/gui/mixins/view_mixin.py +435 -0
  67. setiastro/saspro/halobgon.py +462 -0
  68. setiastro/saspro/header_viewer.py +445 -0
  69. setiastro/saspro/headless_utils.py +88 -0
  70. setiastro/saspro/histogram.py +753 -0
  71. setiastro/saspro/history_explorer.py +939 -0
  72. setiastro/saspro/image_combine.py +414 -0
  73. setiastro/saspro/image_peeker_pro.py +1596 -0
  74. setiastro/saspro/imageops/__init__.py +37 -0
  75. setiastro/saspro/imageops/mdi_snap.py +292 -0
  76. setiastro/saspro/imageops/scnr.py +36 -0
  77. setiastro/saspro/imageops/starbasedwhitebalance.py +210 -0
  78. setiastro/saspro/imageops/stretch.py +244 -0
  79. setiastro/saspro/isophote.py +1179 -0
  80. setiastro/saspro/layers.py +208 -0
  81. setiastro/saspro/layers_dock.py +714 -0
  82. setiastro/saspro/lazy_imports.py +193 -0
  83. setiastro/saspro/legacy/__init__.py +2 -0
  84. setiastro/saspro/legacy/image_manager.py +2226 -0
  85. setiastro/saspro/legacy/numba_utils.py +3659 -0
  86. setiastro/saspro/legacy/xisf.py +1071 -0
  87. setiastro/saspro/linear_fit.py +534 -0
  88. setiastro/saspro/live_stacking.py +1830 -0
  89. setiastro/saspro/log_bus.py +5 -0
  90. setiastro/saspro/logging_config.py +460 -0
  91. setiastro/saspro/luminancerecombine.py +309 -0
  92. setiastro/saspro/main_helpers.py +201 -0
  93. setiastro/saspro/mask_creation.py +928 -0
  94. setiastro/saspro/masks_core.py +56 -0
  95. setiastro/saspro/mdi_widgets.py +353 -0
  96. setiastro/saspro/memory_utils.py +666 -0
  97. setiastro/saspro/metadata_patcher.py +75 -0
  98. setiastro/saspro/mfdeconv.py +3826 -0
  99. setiastro/saspro/mfdeconv_earlystop.py +71 -0
  100. setiastro/saspro/mfdeconvcudnn.py +3263 -0
  101. setiastro/saspro/mfdeconvsport.py +2382 -0
  102. setiastro/saspro/minorbodycatalog.py +567 -0
  103. setiastro/saspro/morphology.py +382 -0
  104. setiastro/saspro/multiscale_decomp.py +1290 -0
  105. setiastro/saspro/nbtorgb_stars.py +531 -0
  106. setiastro/saspro/numba_utils.py +3044 -0
  107. setiastro/saspro/numba_warmup.py +141 -0
  108. setiastro/saspro/ops/__init__.py +9 -0
  109. setiastro/saspro/ops/command_help_dialog.py +623 -0
  110. setiastro/saspro/ops/command_runner.py +217 -0
  111. setiastro/saspro/ops/commands.py +1594 -0
  112. setiastro/saspro/ops/script_editor.py +1102 -0
  113. setiastro/saspro/ops/scripts.py +1413 -0
  114. setiastro/saspro/ops/settings.py +560 -0
  115. setiastro/saspro/parallel_utils.py +554 -0
  116. setiastro/saspro/pedestal.py +121 -0
  117. setiastro/saspro/perfect_palette_picker.py +1053 -0
  118. setiastro/saspro/pipeline.py +110 -0
  119. setiastro/saspro/pixelmath.py +1600 -0
  120. setiastro/saspro/plate_solver.py +2435 -0
  121. setiastro/saspro/project_io.py +797 -0
  122. setiastro/saspro/psf_utils.py +136 -0
  123. setiastro/saspro/psf_viewer.py +549 -0
  124. setiastro/saspro/pyi_rthook_astroquery.py +95 -0
  125. setiastro/saspro/remove_green.py +314 -0
  126. setiastro/saspro/remove_stars.py +1625 -0
  127. setiastro/saspro/remove_stars_preset.py +404 -0
  128. setiastro/saspro/resources.py +472 -0
  129. setiastro/saspro/rgb_combination.py +207 -0
  130. setiastro/saspro/rgb_extract.py +19 -0
  131. setiastro/saspro/rgbalign.py +723 -0
  132. setiastro/saspro/runtime_imports.py +7 -0
  133. setiastro/saspro/runtime_torch.py +754 -0
  134. setiastro/saspro/save_options.py +72 -0
  135. setiastro/saspro/selective_color.py +1552 -0
  136. setiastro/saspro/sfcc.py +1425 -0
  137. setiastro/saspro/shortcuts.py +2807 -0
  138. setiastro/saspro/signature_insert.py +1099 -0
  139. setiastro/saspro/stacking_suite.py +17712 -0
  140. setiastro/saspro/star_alignment.py +7420 -0
  141. setiastro/saspro/star_alignment_preset.py +329 -0
  142. setiastro/saspro/star_metrics.py +49 -0
  143. setiastro/saspro/star_spikes.py +681 -0
  144. setiastro/saspro/star_stretch.py +470 -0
  145. setiastro/saspro/stat_stretch.py +502 -0
  146. setiastro/saspro/status_log_dock.py +78 -0
  147. setiastro/saspro/subwindow.py +3267 -0
  148. setiastro/saspro/supernovaasteroidhunter.py +1712 -0
  149. setiastro/saspro/swap_manager.py +99 -0
  150. setiastro/saspro/torch_backend.py +89 -0
  151. setiastro/saspro/torch_rejection.py +434 -0
  152. setiastro/saspro/view_bundle.py +1555 -0
  153. setiastro/saspro/wavescale_hdr.py +624 -0
  154. setiastro/saspro/wavescale_hdr_preset.py +100 -0
  155. setiastro/saspro/wavescalede.py +657 -0
  156. setiastro/saspro/wavescalede_preset.py +228 -0
  157. setiastro/saspro/wcs_update.py +374 -0
  158. setiastro/saspro/whitebalance.py +456 -0
  159. setiastro/saspro/widgets/__init__.py +48 -0
  160. setiastro/saspro/widgets/common_utilities.py +305 -0
  161. setiastro/saspro/widgets/graphics_views.py +122 -0
  162. setiastro/saspro/widgets/image_utils.py +518 -0
  163. setiastro/saspro/widgets/preview_dialogs.py +280 -0
  164. setiastro/saspro/widgets/spinboxes.py +275 -0
  165. setiastro/saspro/widgets/themed_buttons.py +13 -0
  166. setiastro/saspro/widgets/wavelet_utils.py +299 -0
  167. setiastro/saspro/window_shelf.py +185 -0
  168. setiastro/saspro/xisf.py +1123 -0
  169. setiastrosuitepro-1.6.0.dist-info/METADATA +266 -0
  170. setiastrosuitepro-1.6.0.dist-info/RECORD +174 -0
  171. setiastrosuitepro-1.6.0.dist-info/WHEEL +4 -0
  172. setiastrosuitepro-1.6.0.dist-info/entry_points.txt +6 -0
  173. setiastrosuitepro-1.6.0.dist-info/licenses/LICENSE +674 -0
  174. setiastrosuitepro-1.6.0.dist-info/licenses/license.txt +2580 -0
@@ -0,0 +1,2923 @@
1
+ # pro/blink_comparator_pro.py
2
+ from __future__ import annotations
3
+
4
+ # ⬇️ keep your existing imports used by the code you pasted
5
+ import os
6
+ import re
7
+ import time
8
+ import psutil
9
+ import numpy as np
10
+ from concurrent.futures import ThreadPoolExecutor, as_completed
11
+
12
+ from typing import Optional, List
13
+ from collections import defaultdict
14
+ # Qt
15
+ from PyQt6.QtCore import Qt, QTimer, QEvent, QPointF, QRectF, pyqtSignal, QSettings, QPoint
16
+ from PyQt6.QtGui import (QAction, QIcon, QImage, QPixmap, QBrush, QColor, QPalette,
17
+ QKeySequence, QWheelEvent, QShortcut, QDoubleValidator, QIntValidator)
18
+ from PyQt6.QtWidgets import (
19
+ QWidget, QDialog, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QToolButton,
20
+ QTreeWidget, QTreeWidgetItem, QFileDialog, QMessageBox, QProgressBar,
21
+ QAbstractItemView, QMenu, QSplitter, QStyle, QScrollArea, QSlider, QDoubleSpinBox, QProgressDialog, QComboBox, QLineEdit, QApplication, QGridLayout, QCheckBox, QInputDialog,
22
+ QMdiArea, QDialogButtonBox
23
+ )
24
+ from bisect import bisect_right
25
+ # 3rd-party (your code already expects these)
26
+ import cv2
27
+ import sep
28
+ import pyqtgraph as pg
29
+ from collections import OrderedDict
30
+ from setiastro.saspro.legacy.image_manager import load_image
31
+
32
+ from setiastro.saspro.imageops.stretch import stretch_color_image, stretch_mono_image, siril_style_autostretch
33
+
34
+ from setiastro.saspro.legacy.numba_utils import debayer_fits_fast, debayer_raw_fast
35
+ from setiastro.saspro.widgets.themed_buttons import themed_toolbtn
36
+
37
+
38
+ from setiastro.saspro.star_metrics import measure_stars_sep
39
+
40
+ def _percentile_scale(arr, lo=0.5, hi=99.5):
41
+ a = np.asarray(arr, dtype=np.float32)
42
+ p1 = np.nanpercentile(a, lo)
43
+ p2 = np.nanpercentile(a, hi)
44
+ if not np.isfinite(p1) or not np.isfinite(p2) or p2 <= p1:
45
+ return np.clip(a, 0.0, 1.0)
46
+ return np.clip((a - p1) / (p2 - p1), 0.0, 1.0)
47
+
48
+ # ⬇️ your SASv2 classes — paste them unchanged (Qt6 compatible already)
49
+ class MetricsPanel(QWidget):
50
+ """2×2 grid with clickable dots and draggable thresholds."""
51
+ pointClicked = pyqtSignal(int, int)
52
+ thresholdChanged = pyqtSignal(int, float)
53
+
54
+ def __init__(self, parent=None):
55
+ super().__init__(parent)
56
+ layout = QVBoxLayout(self)
57
+ grid = QGridLayout()
58
+ layout.addLayout(grid)
59
+
60
+ # caching slots
61
+ self._orig_images = None # last list passed
62
+ self.metrics_data = None # list of 4 numpy arrays
63
+ self.flags = None # list of bools
64
+ self._threshold_initialized = [False]*4
65
+ self._open_previews = []
66
+
67
+ self.plots, self.scats, self.lines = [], [], []
68
+ titles = ["FWHM (px)", "Eccentricity", "Background", "Star Count"]
69
+ for idx, title in enumerate(titles):
70
+ pw = pg.PlotWidget()
71
+ pw.setTitle(title)
72
+ pw.showGrid(x=True, y=True, alpha=0.3)
73
+ pw.getPlotItem().getViewBox().setBackgroundColor(
74
+ self.palette().color(self.backgroundRole())
75
+ )
76
+
77
+ scat = pg.ScatterPlotItem(pen=pg.mkPen(None),
78
+ brush=pg.mkBrush(100,100,255,200),
79
+ size=8)
80
+ scat.sigClicked.connect(lambda plot, pts, m=idx: self._on_point_click(m, pts))
81
+ pw.addItem(scat)
82
+
83
+ line = pg.InfiniteLine(pos=0, angle=0, movable=True,
84
+ pen=pg.mkPen('r', width=2))
85
+ line.sigPositionChangeFinished.connect(
86
+ lambda ln, m=idx: self._on_line_move(m, ln))
87
+ pw.addItem(line)
88
+
89
+ grid.addWidget(pw, idx//2, idx%2)
90
+ self.plots.append(pw)
91
+ self.scats.append(scat)
92
+ self.lines.append(line)
93
+
94
+ @staticmethod
95
+ def _compute_one(i_entry):
96
+ idx, entry = i_entry
97
+ img = entry['image_data']
98
+
99
+ # normalize to float32 mono [0..1] exactly like live
100
+ data = np.asarray(img)
101
+ if data.ndim == 3:
102
+ data = data.mean(axis=2)
103
+ if data.dtype == np.uint8:
104
+ data = data.astype(np.float32) / 255.0
105
+ elif data.dtype == np.uint16:
106
+ data = data.astype(np.float32) / 65535.0
107
+ else:
108
+ data = data.astype(np.float32, copy=False)
109
+
110
+ try:
111
+ # --- match old Blink’s SEP pipeline ---
112
+ bkg = sep.Background(data)
113
+ back = bkg.back()
114
+ try:
115
+ gr = float(bkg.globalrms)
116
+ except Exception:
117
+ # some SEP builds only expose per-cell rms map
118
+ gr = float(np.median(np.asarray(bkg.rms(), dtype=np.float32)))
119
+
120
+ cat = sep.extract(
121
+ data - back,
122
+ thresh=7.0,
123
+ err=gr,
124
+ minarea=16,
125
+ clean=True,
126
+ deblend_nthresh=32,
127
+ )
128
+
129
+ if len(cat) > 0:
130
+ # FWHM via geometric-mean sigma (old Blink)
131
+ sig = np.sqrt(cat['a'] * cat['b']).astype(np.float32, copy=False)
132
+ fwhm = float(np.nanmedian(2.3548 * sig))
133
+
134
+ # TRUE eccentricity: e = sqrt(1 - (b/a)^2) (old Blink)
135
+ # guard against divide-by-zero and NaNs
136
+ a = np.maximum(cat['a'].astype(np.float32, copy=False), 1e-12)
137
+ b = np.clip(cat['b'].astype(np.float32, copy=False), 0.0, None)
138
+ q = np.clip(b / a, 0.0, 1.0) # b/a
139
+ e_true = np.sqrt(np.maximum(0.0, 1.0 - q * q))
140
+ ecc = float(np.nanmedian(e_true))
141
+
142
+ star_cnt = int(len(cat))
143
+ else:
144
+ fwhm, ecc, star_cnt = np.nan, np.nan, 0
145
+
146
+ except Exception:
147
+ # same sentinel behavior as before
148
+ fwhm, ecc, star_cnt = 10.0, 1.0, 0
149
+
150
+ orig_back = entry.get('orig_background', np.nan)
151
+ return idx, fwhm, ecc, orig_back, star_cnt
152
+
153
+
154
+ def compute_all_metrics(self, loaded_images):
155
+ """Run SEP over the full list in parallel using threads and cache results."""
156
+ n = len(loaded_images)
157
+ if n == 0:
158
+ # Clear any previous state and bail
159
+ self._orig_images = []
160
+ self.metrics_data = [np.array([])]*4
161
+ self.flags = []
162
+ self._threshold_initialized = [False]*4
163
+ return
164
+
165
+ # Heads-up dialog (as you already had)
166
+ settings = QSettings()
167
+ show = settings.value("metrics/showWarning", True, type=bool)
168
+ if show:
169
+ msg = QMessageBox(self)
170
+ msg.setWindowTitle("Heads-up")
171
+ msg.setText(
172
+ "This is going to use ALL your CPU cores and the UI may lock up until it finishes.\n\n"
173
+ "Continue?"
174
+ )
175
+ msg.setStandardButtons(QMessageBox.StandardButton.Yes |
176
+ QMessageBox.StandardButton.No)
177
+ cb = QCheckBox("Don't show again", msg)
178
+ msg.setCheckBox(cb)
179
+ if msg.exec() != QMessageBox.StandardButton.Yes:
180
+ return
181
+ if cb.isChecked():
182
+ settings.setValue("metrics/showWarning", False)
183
+
184
+ # pre-allocate result arrays
185
+ m0 = np.full(n, np.nan, dtype=np.float32) # FWHM
186
+ m1 = np.full(n, np.nan, dtype=np.float32) # Eccentricity
187
+ m2 = np.full(n, np.nan, dtype=np.float32) # Background (cached)
188
+ m3 = np.full(n, np.nan, dtype=np.float32) # Star count
189
+ flags = [e.get('flagged', False) for e in loaded_images]
190
+
191
+ # progress dialog
192
+ prog = QProgressDialog("Computing frame metrics…", "Cancel", 0, n, self)
193
+ prog.setWindowModality(Qt.WindowModality.WindowModal)
194
+ prog.setMinimumDuration(0)
195
+ prog.setValue(0)
196
+ prog.show()
197
+ QApplication.processEvents()
198
+
199
+ workers = min(os.cpu_count() or 1, 60)
200
+ tasks = [(i, loaded_images[i]) for i in range(n)]
201
+ done = 0 # <-- FIX: initialize before incrementing
202
+
203
+ try:
204
+ with ThreadPoolExecutor(max_workers=workers) as exe:
205
+ futures = {exe.submit(self._compute_one, t): t[0] for t in tasks}
206
+ for fut in as_completed(futures):
207
+ if prog.wasCanceled():
208
+ break
209
+ try:
210
+ idx, fwhm, ecc, orig_back, star_cnt = fut.result()
211
+ except Exception:
212
+ # On failure, leave NaNs/sentinels and continue
213
+ idx, fwhm, ecc, orig_back, star_cnt = futures[fut], np.nan, np.nan, np.nan, 0
214
+ m0[idx], m1[idx], m2[idx], m3[idx] = fwhm, ecc, orig_back, float(star_cnt)
215
+ done += 1
216
+ prog.setValue(done)
217
+ QApplication.processEvents()
218
+ finally:
219
+ prog.close()
220
+
221
+ # stash results
222
+ self._orig_images = loaded_images
223
+ self.metrics_data = [m0, m1, m2, m3]
224
+ self.flags = flags
225
+ self._threshold_initialized = [False]*4
226
+
227
+
228
+ def plot(self, loaded_images, indices=None):
229
+ """
230
+ Plot metrics for loaded_images.
231
+ If indices is given (list/array of ints), only those frames are shown.
232
+ """
233
+ # empty clear
234
+ if not loaded_images:
235
+ self.metrics_data = None
236
+ for pw, scat, line in zip(self.plots, self.scats, self.lines):
237
+ scat.setData(x=[], y=[])
238
+ line.setPos(0)
239
+ pw.getPlotItem().getViewBox().update()
240
+ pw.repaint()
241
+ return
242
+
243
+ # compute & cache on first call or new image list
244
+ if self._orig_images is not loaded_images or self.metrics_data is None:
245
+ self.compute_all_metrics(loaded_images)
246
+
247
+ # default to all indices
248
+ if indices is None:
249
+ indices = np.arange(len(loaded_images), dtype=int)
250
+
251
+ # store for later recoloring
252
+ self._cur_indices = np.array(indices, dtype=int)
253
+
254
+ x = np.arange(len(indices))
255
+
256
+ for m, (pw, scat, line) in enumerate(zip(self.plots, self.scats, self.lines)):
257
+ arr = self.metrics_data[m]
258
+ y = arr[indices]
259
+
260
+ brushes = [
261
+ pg.mkBrush(255,0,0,200) if self.flags[idx] else pg.mkBrush(100,100,255,200)
262
+ for idx in indices
263
+ ]
264
+ scat.setData(x=x, y=y, brush=brushes, pen=pg.mkPen(None), size=8)
265
+
266
+ # initialize threshold line once
267
+ if not self._threshold_initialized[m]:
268
+ mx, mn = np.nanmax(y), np.nanmin(y)
269
+ span = mx-mn if mx!=mn else 1.0
270
+ line.setPos((mx+0.05*span) if m<3 else 0)
271
+ self._threshold_initialized[m] = True
272
+
273
+ def _refresh_scatter_colors(self):
274
+ if not hasattr(self, "_cur_indices") or self._cur_indices is None:
275
+ # default to all indices
276
+ self._cur_indices = np.arange(len(self.flags or []), dtype=int)
277
+
278
+ for scat in self.scats:
279
+ x, y = scat.getData()[:2]
280
+ brushes = []
281
+ for xi in x:
282
+ li = int(xi)
283
+ gi = self._cur_indices[li] if 0 <= li < len(self._cur_indices) else 0
284
+ brushes.append(pg.mkBrush(255,0,0,200) if (self.flags and gi < len(self.flags) and self.flags[gi])
285
+ else pg.mkBrush(100,100,255,200))
286
+ scat.setData(x=x, y=y, brush=brushes)
287
+
288
+ def remove_frames(self, removed_idx: List[int]):
289
+ """
290
+ Drop frames from cached arrays and flags (no recomputation).
291
+ removed_idx: global indices in the *old* ordering.
292
+ """
293
+ if self.metrics_data is None or not removed_idx:
294
+ return
295
+ import numpy as _np
296
+ removed = _np.unique(_np.asarray(removed_idx, dtype=int))
297
+ n = len(self.flags or [])
298
+ if n == 0:
299
+ return
300
+ keep = _np.ones(n, dtype=bool)
301
+ keep[removed[removed < n]] = False
302
+
303
+ # shrink cached arrays and flags
304
+ self.metrics_data = [arr[keep] for arr in self.metrics_data]
305
+ if self.flags is not None:
306
+ self.flags = list(_np.asarray(self.flags)[keep])
307
+
308
+ def refresh_colors_and_status(self):
309
+ """Recolor dots based on self.flags; caller should also update the window status."""
310
+ self._refresh_scatter_colors()
311
+
312
+ def _on_point_click(self, metric_idx, points):
313
+ for pt in points:
314
+ # local index on the currently plotted subset
315
+ li = int(round(pt.pos().x()))
316
+
317
+ # map to global index
318
+ if hasattr(self, "_cur_indices") and self._cur_indices is not None and 0 <= li < len(self._cur_indices):
319
+ gi = int(self._cur_indices[li])
320
+ else:
321
+ gi = li # fallback (e.g., "All")
322
+
323
+ mods = QApplication.keyboardModifiers()
324
+ if mods & Qt.KeyboardModifier.ShiftModifier:
325
+ # preview the correct global frame
326
+ entry = self._orig_images[gi]
327
+ img = entry['image_data']
328
+ is_mono= entry.get('is_mono', False)
329
+ dlg = ImagePreviewDialog(img, is_mono)
330
+ dlg.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose)
331
+ dlg.show()
332
+ self._open_previews.append(dlg)
333
+ dlg.destroyed.connect(lambda _=None, d=dlg:
334
+ self._open_previews.remove(d) if d in self._open_previews else None)
335
+ else:
336
+ # emit the correct global frame index so Blink flags the right leaf
337
+ self.pointClicked.emit(metric_idx, gi)
338
+
339
+ def _on_line_move(self, metric_idx, line):
340
+ self.thresholdChanged.emit(metric_idx, line.value())
341
+
342
+ class MetricsWindow(QWidget):
343
+ def __init__(self, parent=None):
344
+ super().__init__(parent, Qt.WindowType.Window)
345
+ self._thresholds_per_group: dict[str, List[float|None]] = {}
346
+ self.setWindowTitle("Frame Metrics")
347
+ self.resize(800, 600)
348
+
349
+ vbox = QVBoxLayout(self)
350
+
351
+ # ← **new** instructions label
352
+ instr = QLabel(
353
+ "Instructions:\n"
354
+ " • Use the filter dropdown to restrict by FILTER.\n"
355
+ " • Click a dot to flag/unflag a frame.\n"
356
+ " • Shift-click a dot to preview the image.\n"
357
+ " • Drag the red lines to set thresholds.",
358
+ self
359
+ )
360
+ instr.setWordWrap(True)
361
+ instr.setStyleSheet("color: #ccc; font-size: 12px;")
362
+ vbox.addWidget(instr)
363
+
364
+ # → filter selector
365
+ self.group_combo = QComboBox(self)
366
+ self.group_combo.addItem("All")
367
+ self.group_combo.currentTextChanged.connect(self._on_group_change)
368
+ vbox.addWidget(self.group_combo)
369
+
370
+ # → the 2×2 metrics panel
371
+ self.metrics_panel = MetricsPanel(self)
372
+ vbox.addWidget(self.metrics_panel)
373
+
374
+ # keep status up‐to‐date when things happen
375
+ self.metrics_panel.thresholdChanged.connect(self._update_status)
376
+ self.metrics_panel.pointClicked .connect(self._update_status)
377
+
378
+ # ← status label
379
+ self.status_label = QLabel("", self)
380
+ vbox.addWidget(self.status_label)
381
+
382
+ # internal storage
383
+ self._all_images = []
384
+ self._current_indices: Optional[List[int]] = None
385
+
386
+
387
+ def _update_status(self, *args):
388
+ """Recompute and show: Flagged Items X / Y (Z%). Robust to stale indices."""
389
+ flags = getattr(self.metrics_panel, "flags", []) or []
390
+ nflags = len(flags)
391
+
392
+ # what subset are we currently looking at?
393
+ idxs = self._current_indices if self._current_indices is not None else range(nflags)
394
+
395
+ total = 0
396
+ flagged_cnt = 0
397
+
398
+ for i in idxs:
399
+ # i can be np.int64 or a stale index from before a move/delete
400
+ j = int(i)
401
+ if 0 <= j < nflags:
402
+ total += 1
403
+ if flags[j]:
404
+ flagged_cnt += 1
405
+ else:
406
+ # stale index → just skip it
407
+ continue
408
+
409
+ pct = (flagged_cnt / total * 100.0) if total else 0.0
410
+ self.status_label.setText(f"Flagged Items {flagged_cnt}/{total} ({pct:.1f}%)")
411
+
412
+
413
+ def set_images(self, loaded_images, order=None):
414
+ self._all_images = loaded_images
415
+ self._order_all = list(order) if order is not None else list(range(len(loaded_images)))
416
+
417
+ # ─── rebuild the combo-list of FILTER groups ─────────────
418
+ self.group_combo.blockSignals(True)
419
+ self.group_combo.clear()
420
+ self.group_combo.addItem("All")
421
+ seen = set()
422
+ for entry in loaded_images:
423
+ filt = entry.get('header', {}).get('FILTER', 'Unknown')
424
+ if filt not in seen:
425
+ seen.add(filt)
426
+ self.group_combo.addItem(filt)
427
+ self.group_combo.blockSignals(False)
428
+
429
+ # ─── reset & seed per-group thresholds ────────────────────
430
+ self._thresholds_per_group.clear()
431
+ self._thresholds_per_group["All"] = [None]*4
432
+ for entry in loaded_images:
433
+ filt = entry.get('header', {}).get('FILTER', 'Unknown')
434
+ if filt not in self._thresholds_per_group:
435
+ self._thresholds_per_group[filt] = [None]*4
436
+
437
+ # ─── compute & cache all metrics once ────────────────────
438
+ self.metrics_panel.compute_all_metrics(self._all_images)
439
+
440
+ # ─── show “All” by default and plot ───────────────────────
441
+ self._current_indices = self._order_all
442
+ self._apply_thresholds("All")
443
+ self.metrics_panel.plot(self._all_images, indices=self._current_indices)
444
+ self._update_status()
445
+
446
+ def _reindex_list_after_remove(self, lst: List[int] | None, removed: List[int]) -> List[int] | None:
447
+ """Return lst with removed indices dropped and others shifted."""
448
+ if lst is None:
449
+ return None
450
+ from bisect import bisect_right
451
+ removed = sorted(set(int(i) for i in removed))
452
+ rset = set(removed)
453
+ def new_idx(old):
454
+ return old - bisect_right(removed, old)
455
+ return [new_idx(i) for i in lst if i not in rset]
456
+
457
+ def _rebuild_groups_from_images(self):
458
+ """Rebuild the FILTER combobox from current _all_images, keep current if possible."""
459
+ cur = self.group_combo.currentText()
460
+ self.group_combo.blockSignals(True)
461
+ self.group_combo.clear()
462
+ self.group_combo.addItem("All")
463
+ seen = set()
464
+ for entry in self._all_images:
465
+ filt = (entry.get('header', {}) or {}).get('FILTER', 'Unknown')
466
+ if filt not in seen:
467
+ self.group_combo.addItem(filt)
468
+ seen.add(filt)
469
+ self.group_combo.blockSignals(False)
470
+ # restore selection if still valid
471
+ idx = self.group_combo.findText(cur)
472
+ if idx >= 0:
473
+ self.group_combo.setCurrentIndex(idx)
474
+ else:
475
+ self.group_combo.setCurrentIndex(0)
476
+
477
+ def remove_indices(self, removed: List[int]):
478
+ """
479
+ Called when some frames were deleted/moved out of the list.
480
+ Does NOT recompute metrics. Just trims cached arrays and re-plots.
481
+ """
482
+ if not removed:
483
+ return
484
+ removed = sorted(set(int(i) for i in removed))
485
+
486
+ # 1) shrink cached arrays in the panel
487
+ self.metrics_panel.remove_frames(removed)
488
+
489
+ # 2) update our “master” list and ordering (object identity unchanged)
490
+ # (BlinkTab will already have mutated the underlying list for us)
491
+ self._order_all = self._reindex_list_after_remove(self._order_all, removed)
492
+ self._current_indices = self._reindex_list_after_remove(self._current_indices, removed)
493
+
494
+ # 3) rebuild group list (filters may have disappeared)
495
+ self._rebuild_groups_from_images()
496
+
497
+ # 4) replot current group with updated order
498
+ indices = self._current_indices if self._current_indices is not None else self._order_all
499
+ self.metrics_panel.plot(self._all_images, indices=indices)
500
+
501
+ # 5) recolor & status
502
+ self.metrics_panel.refresh_colors_and_status()
503
+ self._update_status()
504
+
505
+ def _on_group_change(self, name: str):
506
+ if name == "All":
507
+ self._current_indices = self._order_all
508
+ else:
509
+ # preserve Tree order inside the chosen FILTER
510
+ filt = name
511
+ self._current_indices = [
512
+ i for i in self._order_all
513
+ if (self._all_images[i].get('header', {}) or {}).get('FILTER', 'Unknown') == filt
514
+ ]
515
+ self._apply_thresholds(name)
516
+ self.metrics_panel.plot(self._all_images, indices=self._current_indices)
517
+
518
+ def _on_panel_threshold_change(self, metric_idx: int, new_val: float):
519
+ """User just dragged a threshold line."""
520
+ grp = self.group_combo.currentText()
521
+ # save it for this group
522
+ self._thresholds_per_group[grp][metric_idx] = new_val
523
+
524
+ # (if you also want immediate re-flagging in the tree, keep your BlinkTab logic hooked here)
525
+
526
+ def _apply_thresholds(self, group_name: str):
527
+ """Restore the four InfiniteLine positions for a given group."""
528
+ saved = self._thresholds_per_group.get(group_name, [None]*4)
529
+ for idx, line in enumerate(self.metrics_panel.lines):
530
+ if saved[idx] is not None:
531
+ line.setPos(saved[idx])
532
+ # if saved[idx] is None, we leave it so that
533
+ # the panel’s own auto-init can run on next plot()
534
+
535
+ def update_metrics(self, loaded_images, order=None):
536
+ if loaded_images is not self._all_images:
537
+ self.set_images(loaded_images, order=order)
538
+ else:
539
+ if order is not None:
540
+ self._order_all = list(order)
541
+ # re-plot the current group with the new ordering
542
+ self._on_group_change(self.group_combo.currentText())
543
+
544
+ class BlinkComparatorPro(QDialog):
545
+ sendToStacking = pyqtSignal(list, str)
546
+
547
+ def __init__(self, doc_manager=None, parent=None):
548
+ super().__init__(parent)
549
+ self.doc_manager = doc_manager
550
+ self.setWindowTitle("Blink Comparator")
551
+ self.resize(1200, 700)
552
+
553
+ self.tab = BlinkTab(doc_manager=self.doc_manager, parent=self)
554
+ layout = QVBoxLayout(self)
555
+ layout.addWidget(self.tab)
556
+ self.setLayout(layout)
557
+
558
+ # bridge tab → dialog
559
+ self.tab.sendToStacking.connect(self.sendToStacking)
560
+
561
+
562
+ class BlinkTab(QWidget):
563
+ imagesChanged = pyqtSignal(int)
564
+ sendToStacking = pyqtSignal(list, str)
565
+ def __init__(self, image_manager=None, doc_manager=None, parent=None):
566
+ super().__init__(parent)
567
+
568
+ self.image_paths = [] # Store the file paths of loaded images
569
+ self.loaded_images = [] # Store the image objects (as numpy arrays)
570
+ self.image_labels = [] # Store corresponding file names for the TreeWidget
571
+ self.doc_manager = doc_manager # ⬅️ new
572
+ self.image_manager = image_manager # ⬅️ ensure we don't use it
573
+ self.metrics_window: Optional[MetricsWindow] = None
574
+ self.zoom_level = 0.5 # Default zoom level
575
+ self.dragging = False # Track whether the mouse is dragging
576
+ self.last_mouse_pos = None # Store the last mouse position
577
+ self.thresholds_by_group: dict[str, List[float|None]] = {}
578
+ self.aggressive_stretch_enabled = False
579
+ self.current_sigma = 3.7
580
+ self.current_pixmap = None
581
+ self._last_preview_name = None
582
+ self._pending_preview_timer = QTimer(self)
583
+ self._pending_preview_timer.setSingleShot(True)
584
+ self._pending_preview_timer.setInterval(40) # 40–80ms is plenty
585
+ self._pending_preview_item = None
586
+ self._pending_preview_timer.timeout.connect(self._do_preview_update)
587
+ self.play_fps = 1 # default fps (200 ms/frame)
588
+ self._view_center_norm = None
589
+ self.initUI()
590
+ self.init_shortcuts()
591
+
592
+ def initUI(self):
593
+ main_layout = QHBoxLayout(self)
594
+
595
+
596
+ # Create a QSplitter to allow resizing between left and right panels
597
+ splitter = QSplitter(Qt.Orientation.Horizontal, self)
598
+
599
+ # Left Column for the file loading and TreeView
600
+ left_widget = QWidget(self)
601
+ left_layout = QVBoxLayout(left_widget)
602
+
603
+ # --------------------
604
+ # Instruction Label
605
+ # --------------------
606
+ instruction_text = "Press 'F' to flag/unflag an image.\nRight-click on an image for more options."
607
+ self.instruction_label = QLabel(instruction_text, self)
608
+ self.instruction_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
609
+ self.instruction_label.setWordWrap(True)
610
+ self.instruction_label.setStyleSheet("font-weight: bold;") # Optional: Make the text bold for emphasis
611
+
612
+ self.instruction_label.setStyleSheet(f"""
613
+ QLabel {{
614
+ font-weight: bold;
615
+ }}
616
+ """)
617
+
618
+ # Add the instruction label to the left layout at the top
619
+ left_layout.addWidget(self.instruction_label)
620
+
621
+ # Horizontal layout for "Select Images" and "Select Directory" buttons
622
+ button_layout = QHBoxLayout()
623
+
624
+ # "Select Images" Button
625
+ self.fileButton = QPushButton('Select Images', self)
626
+ self.fileButton.clicked.connect(self.openFileDialog)
627
+ button_layout.addWidget(self.fileButton)
628
+
629
+ # "Select Directory" Button
630
+ self.dirButton = QPushButton('Select Directory', self)
631
+ self.dirButton.clicked.connect(self.openDirectoryDialog)
632
+ button_layout.addWidget(self.dirButton)
633
+
634
+ self.addButton = QPushButton("Add Additional", self)
635
+ self.addButton.clicked.connect(self.addAdditionalImages)
636
+ button_layout.addWidget(self.addButton)
637
+
638
+ left_layout.addLayout(button_layout)
639
+
640
+ self.metrics_button = QPushButton("Show Metrics", self)
641
+ self.metrics_button.clicked.connect(self.show_metrics)
642
+ left_layout.addWidget(self.metrics_button)
643
+
644
+ push_row = QHBoxLayout()
645
+ self.send_lights_btn = QPushButton("→ Stacking: Lights", self)
646
+ self.send_lights_btn.setToolTip("Send selected (or all) blink files to the Stacking Suite → Light tab")
647
+ self.send_lights_btn.clicked.connect(self._send_to_stacking_lights)
648
+ push_row.addWidget(self.send_lights_btn)
649
+
650
+ self.send_integ_btn = QPushButton("→ Stacking: Integration", self)
651
+ self.send_integ_btn.setToolTip("Send selected (or all) blink files to the Stacking Suite → Image Integration tab")
652
+ self.send_integ_btn.clicked.connect(self._send_to_stacking_integration)
653
+ push_row.addWidget(self.send_integ_btn)
654
+
655
+ left_layout.addLayout(push_row)
656
+
657
+ # Playback controls (left arrow, play, pause, right arrow)
658
+ playback_controls_layout = QHBoxLayout()
659
+
660
+ # Left Arrow Button
661
+ self.left_arrow_button = QPushButton(self)
662
+ self.left_arrow_button.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_ArrowLeft))
663
+ self.left_arrow_button.clicked.connect(self.previous_item)
664
+ playback_controls_layout.addWidget(self.left_arrow_button)
665
+
666
+ # Play Button
667
+ self.play_button = QPushButton(self)
668
+ self.play_button.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_MediaPlay))
669
+ self.play_button.clicked.connect(self.start_playback)
670
+ playback_controls_layout.addWidget(self.play_button)
671
+
672
+ # Pause Button
673
+ self.pause_button = QPushButton(self)
674
+ self.pause_button.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_MediaPause))
675
+ self.pause_button.clicked.connect(self.stop_playback)
676
+ playback_controls_layout.addWidget(self.pause_button)
677
+
678
+ # Right Arrow Button
679
+ self.right_arrow_button = QPushButton(self)
680
+ self.right_arrow_button.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_ArrowRight))
681
+ self.right_arrow_button.clicked.connect(self.next_item)
682
+ playback_controls_layout.addWidget(self.right_arrow_button)
683
+
684
+ left_layout.addLayout(playback_controls_layout)
685
+
686
+ # ----- Playback speed controls -----
687
+ # ----- Playback speed controls (0.1–10.0 fps) -----
688
+ speed_layout = QHBoxLayout()
689
+
690
+ speed_label = QLabel("Speed:", self)
691
+ speed_layout.addWidget(speed_label)
692
+
693
+ # Slider maps 1..100 -> 0.1..10.0 fps
694
+ self.speed_slider = QSlider(Qt.Orientation.Horizontal, self)
695
+ self.speed_slider.setRange(1, 100)
696
+ self.speed_slider.setValue(int(round(self.play_fps * 10))) # play_fps is float
697
+ self.speed_slider.setTickPosition(QSlider.TickPosition.NoTicks)
698
+ self.speed_slider.setToolTip("Playback speed (0.1–10.0 fps)")
699
+ speed_layout.addWidget(self.speed_slider, 1)
700
+
701
+ # Custom float spin (your class)
702
+ self.speed_spin = CustomDoubleSpinBox(
703
+ minimum=0.1, maximum=10.0, initial=self.play_fps, step=0.1, parent=self
704
+ )
705
+ speed_layout.addWidget(self.speed_spin)
706
+
707
+ # IMPORTANT: remove any old direct connects like:
708
+ # self.speed_slider.valueChanged.connect(self.speed_spin.setValue)
709
+ # self.speed_spin.valueChanged.connect(self.speed_slider.setValue)
710
+
711
+ # Use lambdas to cast types correctly
712
+ self.speed_slider.valueChanged.connect(lambda v: self.speed_spin.setValue(v / 10.0)) # int -> float
713
+ self.speed_spin.valueChanged.connect(lambda f: self.speed_slider.setValue(int(round(f * 10)))) # float -> int
714
+
715
+ self.speed_slider.valueChanged.connect(self._apply_playback_interval)
716
+ self.speed_spin.valueChanged.connect(self._apply_playback_interval)
717
+
718
+ left_layout.addLayout(speed_layout)
719
+
720
+ self.export_button = QPushButton("Export Video…", self)
721
+ self.export_button.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_DialogSaveButton))
722
+ self.export_button.clicked.connect(self.export_blink_video)
723
+ left_layout.addWidget(self.export_button)
724
+
725
+ # Tree view for file names
726
+ self.fileTree = QTreeWidget(self)
727
+ self.fileTree.setColumnCount(1)
728
+ self.fileTree.setHeaderLabels(["Image Files"])
729
+ self.fileTree.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection) # Allow multiple selections
730
+ #self.fileTree.itemClicked.connect(self.on_item_clicked)
731
+ self.fileTree.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
732
+ self.fileTree.customContextMenuRequested.connect(self.on_right_click)
733
+ self.fileTree.currentItemChanged.connect(self._on_current_item_changed_safe)
734
+ self.fileTree.setStyleSheet("""
735
+ QTreeWidget::item:selected {
736
+ background-color: #3a75c4; /* Blue background for selected items */
737
+ color: #ffffff; /* White text color */
738
+ }
739
+ """)
740
+ left_layout.addWidget(self.fileTree)
741
+
742
+ # "Clear Flags" Button
743
+ self.clearFlagsButton = QPushButton('Clear Flags', self)
744
+ self.clearFlagsButton.clicked.connect(self.clearFlags)
745
+ left_layout.addWidget(self.clearFlagsButton)
746
+
747
+ # "Clear Images" Button
748
+ self.clearButton = QPushButton('Clear Images', self)
749
+ self.clearButton.clicked.connect(self.clearImages)
750
+ left_layout.addWidget(self.clearButton)
751
+
752
+ # Add progress bar
753
+ self.progress_bar = QProgressBar(self)
754
+ self.progress_bar.setRange(0, 100)
755
+ left_layout.addWidget(self.progress_bar)
756
+
757
+ # Add loading message label
758
+ self.loading_label = QLabel("Loading images...", self)
759
+ left_layout.addWidget(self.loading_label)
760
+ self.imagesChanged.emit(len(self.loaded_images))
761
+
762
+ # Set the layout for the left widget
763
+ left_widget.setLayout(left_layout)
764
+
765
+ # Add the left widget to the splitter
766
+ splitter.addWidget(left_widget)
767
+
768
+ # Right Column for Image Preview
769
+ right_widget = QWidget(self)
770
+ right_layout = QVBoxLayout(right_widget)
771
+
772
+ # Zoom / preview toolbar (standardized)
773
+ zoom_controls_layout = QHBoxLayout()
774
+
775
+ self.zoom_in_btn = themed_toolbtn("zoom-in", "Zoom In")
776
+ self.zoom_out_btn = themed_toolbtn("zoom-out", "Zoom Out")
777
+ self.fit_btn = themed_toolbtn("zoom-fit-best", "Fit to Preview")
778
+
779
+ self.zoom_in_btn.clicked.connect(self.zoom_in)
780
+ self.zoom_out_btn.clicked.connect(self.zoom_out)
781
+ self.fit_btn.clicked.connect(self.fit_to_preview)
782
+
783
+ zoom_controls_layout.addWidget(self.zoom_in_btn)
784
+ zoom_controls_layout.addWidget(self.zoom_out_btn)
785
+ zoom_controls_layout.addWidget(self.fit_btn)
786
+
787
+ zoom_controls_layout.addStretch(1)
788
+
789
+ # Keep Aggressive Stretch as a text toggle (it’s not really a zoom action)
790
+ self.aggressive_button = QPushButton("Aggressive Stretch", self)
791
+ self.aggressive_button.setCheckable(True)
792
+ self.aggressive_button.clicked.connect(self.toggle_aggressive)
793
+ zoom_controls_layout.addWidget(self.aggressive_button)
794
+
795
+ right_layout.addLayout(zoom_controls_layout)
796
+
797
+ # Scroll area for the preview
798
+ self.scroll_area = QScrollArea(self)
799
+ self.scroll_area.setWidgetResizable(True)
800
+ self.scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
801
+ self.scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
802
+ self.scroll_area.viewport().installEventFilter(self)
803
+
804
+ # QLabel for the image preview
805
+ self.preview_label = QLabel(self)
806
+ self.preview_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
807
+ self.scroll_area.setWidget(self.preview_label)
808
+
809
+ right_layout.addWidget(self.scroll_area)
810
+
811
+ # Set the layout for the right widget
812
+ right_widget.setLayout(right_layout)
813
+
814
+ # Add the right widget to the splitter
815
+ splitter.addWidget(right_widget)
816
+
817
+ # Set initial splitter sizes
818
+ splitter.setSizes([300, 700]) # Adjust proportions as needed
819
+
820
+ # Add the splitter to the main layout
821
+ main_layout.addWidget(splitter)
822
+
823
+ # Set the main layout for the widget
824
+ self.setLayout(main_layout)
825
+
826
+ # Initialize playback timer
827
+ self.playback_timer = QTimer(self)
828
+ self._apply_playback_interval() # sets interval based on self.play_fps
829
+ self.playback_timer.timeout.connect(self.next_item)
830
+
831
+ # Connect the selection change signal to update the preview when arrow keys are used
832
+ self.fileTree.selectionModel().selectionChanged.connect(self.on_selection_changed)
833
+ self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
834
+
835
+ self.scroll_area.horizontalScrollBar().valueChanged.connect(lambda _: self._capture_view_center_norm())
836
+ self.scroll_area.verticalScrollBar().valueChanged.connect(lambda _: self._capture_view_center_norm())
837
+ self.imagesChanged.connect(self._update_loaded_count_label)
838
+
839
+ @staticmethod
840
+ def _ensure_float01(img):
841
+ """
842
+ Convert to float32 and force into [0..1] using:
843
+ - if min < 0: subtract min
844
+ - if max > 1: divide by max
845
+ Works for mono or RGB. Handles NaN/Inf safely.
846
+ """
847
+ arr = np.asarray(img, dtype=np.float32)
848
+
849
+ finite = np.isfinite(arr)
850
+ if not finite.any():
851
+ return np.zeros_like(arr, dtype=np.float32)
852
+
853
+ mn = float(arr[finite].min())
854
+ if mn < 0.0:
855
+ arr = arr - mn
856
+
857
+ # recompute after possible shift
858
+ finite = np.isfinite(arr)
859
+ mx = float(arr[finite].max()) if finite.any() else 0.0
860
+ if mx > 1.0:
861
+ if mx > 0.0:
862
+ arr = arr / mx
863
+
864
+ return np.clip(arr, 0.0, 1.0)
865
+
866
+
867
+ def _aggressive_display_boost(self, x01: np.ndarray, strength: float = 3.7) -> np.ndarray:
868
+ """
869
+ Stronger display stretch on top of an already stretched image.
870
+ Input/Output are float32 in [0..1].
871
+ Robust: percentile normalize + asinh boost.
872
+ """
873
+ x = np.asarray(x01, dtype=np.float32)
874
+ x = np.nan_to_num(x, nan=0.0, posinf=1.0, neginf=0.0)
875
+ x = np.clip(x, 0.0, 1.0)
876
+
877
+ # Robust normalize: ignore extreme outliers so we actually expand contrast
878
+ lo = float(np.percentile(x, 0.25))
879
+ hi = float(np.percentile(x, 99.75))
880
+ if not np.isfinite(lo) or not np.isfinite(hi) or hi <= lo + 1e-8:
881
+ return x # nothing to do, but never return black
882
+
883
+ y = (x - lo) / (hi - lo)
884
+ y = np.clip(y, 0.0, 1.0)
885
+
886
+ # Asinh boost (stronger -> more aggressive midtone lift)
887
+ k = max(1.0, float(strength) * 1.25) # tune multiplier to taste
888
+ y = np.arcsinh(k * y) / np.arcsinh(k)
889
+
890
+ return np.clip(y, 0.0, 1.0)
891
+
892
+
893
+ # --------------------------------------------
894
+ # NEW: collect paths & emit to stacking
895
+ # --------------------------------------------
896
+ def _collect_paths_for_stacking(self) -> list[str]:
897
+ """
898
+ Priority:
899
+ 1) if user has rows selected in the tree → use those
900
+ 2) else → use all loaded image_paths
901
+ """
902
+ paths: list[str] = []
903
+
904
+ selected_items = self.fileTree.selectedItems()
905
+ if selected_items:
906
+ for it in selected_items:
907
+ p = it.data(0, Qt.ItemDataRole.UserRole)
908
+ if not p:
909
+ # some code uses text as path, fall back
910
+ p = it.text(0)
911
+ if p:
912
+ paths.append(p)
913
+ else:
914
+ # no selection → send all
915
+ for p in self.image_paths:
916
+ if p:
917
+ paths.append(p)
918
+
919
+ # de-dup, keep order
920
+ seen = set()
921
+ unique_paths = []
922
+ for p in paths:
923
+ if p not in seen:
924
+ seen.add(p)
925
+ unique_paths.append(p)
926
+ return unique_paths
927
+
928
+ def _send_to_stacking_lights(self):
929
+ paths = self._collect_paths_for_stacking()
930
+ if not paths:
931
+ QMessageBox.information(self, "No images", "There are no images to send.")
932
+ return
933
+ self.sendToStacking.emit(paths, "lights")
934
+
935
+ def _send_to_stacking_integration(self):
936
+ paths = self._collect_paths_for_stacking()
937
+ if not paths:
938
+ QMessageBox.information(self, "No images", "There are no images to send.")
939
+ return
940
+ self.sendToStacking.emit(paths, "integration")
941
+
942
+
943
+ def export_blink_video(self):
944
+ """Export the blink sequence to a video. Defaults to all frames in current tree order."""
945
+ # Ensure we have frames
946
+ leaves = self.get_all_leaf_items()
947
+ if not leaves:
948
+ QMessageBox.information(self, "No Images", "Load images before exporting.")
949
+ return
950
+
951
+ # Ask options first (size, fps, selection scope)
952
+ opts = self._ask_video_options(default_fps=float(self.play_fps))
953
+ if opts is None:
954
+ return
955
+ target_w, target_h = opts["size"]
956
+ fps = max(0.1, min(60.0, float(opts["fps"])))
957
+ only_selected = bool(opts.get("only_selected", False))
958
+
959
+ # Decide frame order
960
+ if only_selected:
961
+ sel_leaves = [it for it in self.fileTree.selectedItems() if it.childCount() == 0]
962
+ if not sel_leaves:
963
+ QMessageBox.information(self, "No Selection", "No individual frames selected.")
964
+ return
965
+ names = {it.text(0).lstrip("⚠️ ").strip() for it in sel_leaves}
966
+ order = [i for i in self._tree_order_indices()
967
+ if os.path.basename(self.image_paths[i]) in names]
968
+ else:
969
+ order = self._tree_order_indices()
970
+
971
+ if not order:
972
+ QMessageBox.information(self, "No Frames", "Nothing to export.")
973
+ return
974
+
975
+ if len(order) < 2:
976
+ ret = QMessageBox.question(
977
+ self, "Only one frame",
978
+ "You're about to export a video with a single frame. Continue?",
979
+ QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
980
+ QMessageBox.StandardButton.No,
981
+ )
982
+ if ret != QMessageBox.StandardButton.Yes:
983
+ return
984
+
985
+ # Ask where to save
986
+ out_path, _ = QFileDialog.getSaveFileName(
987
+ self, "Export Blink Video", "blink.mp4", "Video (*.mp4 *.avi)"
988
+ )
989
+ if not out_path:
990
+ return
991
+ # Let _open_video_writer_portable decide the real extension; we pass requested
992
+ writer, out_path, backend = self._open_video_writer_portable(out_path, (target_w, target_h), fps)
993
+ if writer is None:
994
+ QMessageBox.critical(self, "Export",
995
+ "No compatible video codec found.\n\n"
996
+ "Tip: install FFmpeg or `pip install imageio[ffmpeg]` for a portable fallback."
997
+ )
998
+ return
999
+
1000
+ # Progress UI
1001
+ prog = QProgressDialog("Rendering video…", "Cancel", 0, len(order), self)
1002
+ prog.setWindowTitle("Export Blink Video")
1003
+ prog.setAutoClose(True)
1004
+ prog.setMinimumDuration(300)
1005
+
1006
+ using_imageio = (backend == "imageio-ffmpeg")
1007
+ frames_written = 0
1008
+
1009
+ try:
1010
+ for i, idx in enumerate(order):
1011
+ if prog.wasCanceled():
1012
+ break
1013
+
1014
+ entry = self.loaded_images[idx]
1015
+ f = self._make_display_frame(entry) # uint8, gray or RGB
1016
+
1017
+ # Ensure 3-channel RGB
1018
+ if f.ndim == 2:
1019
+ f = cv2.cvtColor(f, cv2.COLOR_GRAY2RGB)
1020
+
1021
+ # Letterbox into target (keep aspect)
1022
+ tw, th = (target_w, target_h)
1023
+ h, w = f.shape[:2]
1024
+ s = min(tw / float(w), th / float(h))
1025
+ nw, nh = max(1, int(round(w * s))), max(1, int(round(h * s)))
1026
+ resized = cv2.resize(f, (nw, nh), interpolation=cv2.INTER_AREA)
1027
+ rgb_canvas = np.zeros((th, tw, 3), dtype=np.uint8)
1028
+ x0, y0 = (tw - nw) // 2, (th - nh) // 2
1029
+ rgb_canvas[y0:y0+nh, x0:x0+nw] = resized
1030
+
1031
+ if using_imageio:
1032
+ writer.append_data(rgb_canvas) # RGB
1033
+ else:
1034
+ writer.write(cv2.cvtColor(rgb_canvas, cv2.COLOR_RGB2BGR)) # BGR
1035
+ frames_written += 1
1036
+
1037
+ prog.setValue(i + 1)
1038
+ QApplication.processEvents()
1039
+ finally:
1040
+ try:
1041
+ writer.close() if using_imageio else writer.release()
1042
+ except Exception:
1043
+ pass
1044
+
1045
+ if prog.wasCanceled():
1046
+ try:
1047
+ os.remove(out_path)
1048
+ except Exception:
1049
+ pass
1050
+ QMessageBox.information(self, "Export", "Export canceled.")
1051
+ return
1052
+
1053
+ if frames_written == 0:
1054
+ QMessageBox.critical(self, "Export", "No frames were written (codec/back-end issue?).")
1055
+ return
1056
+
1057
+ QMessageBox.information(self, "Export", f"Saved: {out_path}\nFrames: {frames_written} @ {fps} fps")
1058
+
1059
+
1060
+
1061
+ def _ask_video_options(self, default_fps: float):
1062
+ """Options dialog for size, fps, and whether to limit to current selection."""
1063
+ dlg = QDialog(self)
1064
+ dlg.setWindowTitle("Video Options")
1065
+ layout = QGridLayout(dlg)
1066
+
1067
+ # Size
1068
+ layout.addWidget(QLabel("Size:"), 0, 0)
1069
+ size_combo = QComboBox(dlg)
1070
+ size_combo.addItem("HD 1280×720", (1280, 720))
1071
+ size_combo.addItem("Full HD 1920×1080", (1920, 1080))
1072
+ size_combo.addItem("Square 1080×1080", (1080, 1080))
1073
+ size_combo.setCurrentIndex(0)
1074
+ layout.addWidget(size_combo, 0, 1)
1075
+
1076
+ # FPS
1077
+ layout.addWidget(QLabel("FPS:"), 1, 0)
1078
+ fps_edit = QDoubleSpinBox(dlg)
1079
+ fps_edit.setRange(0.1, 60.0)
1080
+ fps_edit.setDecimals(2)
1081
+ fps_edit.setSingleStep(0.1)
1082
+ fps_edit.setValue(float(default_fps))
1083
+ layout.addWidget(fps_edit, 1, 1)
1084
+
1085
+ # Only selected?
1086
+ only_selected = QCheckBox("Export only selected frames", dlg)
1087
+ only_selected.setChecked(False) # default: export everything in tree order
1088
+ layout.addWidget(only_selected, 2, 0, 1, 2)
1089
+
1090
+ # Buttons
1091
+ btns = QHBoxLayout()
1092
+ ok = QPushButton("OK", dlg); cancel = QPushButton("Cancel", dlg)
1093
+ ok.clicked.connect(dlg.accept); cancel.clicked.connect(dlg.reject)
1094
+ btns.addWidget(ok); btns.addWidget(cancel)
1095
+ layout.addLayout(btns, 3, 0, 1, 2)
1096
+
1097
+ if dlg.exec() != QDialog.DialogCode.Accepted:
1098
+ return None
1099
+ return {
1100
+ "size": size_combo.currentData(),
1101
+ "fps": fps_edit.value(),
1102
+ "only_selected": only_selected.isChecked()
1103
+ }
1104
+
1105
+
1106
+
1107
+ def _make_display_frame(self, entry):
1108
+ stored = entry['image_data']
1109
+ use_aggr = bool(self.aggressive_stretch_enabled)
1110
+
1111
+ if not use_aggr:
1112
+ if stored.dtype == np.uint8:
1113
+ disp8 = stored
1114
+ elif stored.dtype == np.uint16:
1115
+ disp8 = (stored >> 8).astype(np.uint8)
1116
+ else:
1117
+ disp8 = (np.clip(stored, 0.0, 1.0) * 255.0).astype(np.uint8)
1118
+ return disp8
1119
+
1120
+ base01 = self._as_float01(stored)
1121
+
1122
+ if base01.ndim == 2:
1123
+ disp01 = self._aggressive_display_boost(base01, strength=self.current_sigma)
1124
+ else:
1125
+ lum = base01.mean(axis=2).astype(np.float32)
1126
+ lum_boost = self._aggressive_display_boost(lum, strength=self.current_sigma)
1127
+ gain = lum_boost / (lum + 1e-6)
1128
+ disp01 = np.clip(base01 * gain[..., None], 0.0, 1.0)
1129
+
1130
+ return (disp01 * 255.0).astype(np.uint8)
1131
+
1132
+
1133
+
1134
+ def _fit_letterbox(self, frame_bgr_or_rgb, target_size):
1135
+ """
1136
+ Fit 'frame' into target_size with letterboxing (black borders).
1137
+ Accepts uint8, shape (H,W,3). Returns BGR uint8 (H_t,W_t,3).
1138
+ """
1139
+ tw, th = target_size
1140
+ h, w = frame_bgr_or_rgb.shape[:2]
1141
+ # Compute scale to fit inside
1142
+ s = min(tw / float(w), th / float(h))
1143
+ nw, nh = max(1, int(round(w * s))), max(1, int(round(h * s)))
1144
+
1145
+ # Resize (OpenCV uses BGR—this function doesn’t swap channels)
1146
+ resized = cv2.resize(frame_bgr_or_rgb, (nw, nh), interpolation=cv2.INTER_AREA)
1147
+
1148
+ # Pad into target
1149
+ out = np.zeros((th, tw, 3), dtype=np.uint8)
1150
+ x0 = (tw - nw) // 2
1151
+ y0 = (th - nh) // 2
1152
+ out[y0:y0+nh, x0:x0+nw] = resized if resized.ndim == 3 else cv2.cvtColor(resized, cv2.COLOR_GRAY2BGR)
1153
+ return out
1154
+
1155
+ def _open_video_writer_portable(self, requested_path: str, size: tuple[int, int], fps: float):
1156
+ """
1157
+ Try several (container, fourcc) combos that work across platforms.
1158
+ Returns (writer, out_path, backend_name). If OpenCV fails, tries imageio-ffmpeg.
1159
+ Never writes a probe frame, so no accidental extra first frame.
1160
+ """
1161
+ tw, th = size
1162
+ candidates = [
1163
+ (".mp4", "mp4v", "OpenCV-mp4v"),
1164
+ (".mp4", "avc1", "OpenCV-avc1"), # H.264 if available
1165
+ (".mp4", "H264", "OpenCV-H264"),
1166
+ (".avi", "MJPG", "OpenCV-MJPG"),
1167
+ (".avi", "XVID", "OpenCV-XVID"),
1168
+ ]
1169
+ base, _ = os.path.splitext(requested_path)
1170
+
1171
+ # Try OpenCV containers/codecs first (without writing a test frame)
1172
+ for ext, fourcc_tag, label in candidates:
1173
+ out_path = base + ext
1174
+ fourcc = cv2.VideoWriter_fourcc(*fourcc_tag)
1175
+
1176
+ # open/close once to check the container initialization
1177
+ vw = cv2.VideoWriter(out_path, fourcc, float(fps), (tw, th))
1178
+ ok = vw.isOpened()
1179
+ try:
1180
+ vw.release()
1181
+ except Exception:
1182
+ pass
1183
+
1184
+ # some backends leave a tiny stub — clean it up before the real open
1185
+ try:
1186
+ if os.path.exists(out_path) and os.path.getsize(out_path) < 1024:
1187
+ os.remove(out_path)
1188
+ except Exception:
1189
+ pass
1190
+
1191
+ if ok:
1192
+ vw2 = cv2.VideoWriter(out_path, fourcc, float(fps), (tw, th))
1193
+ if vw2.isOpened():
1194
+ return vw2, out_path, label
1195
+
1196
+ # Fallback: imageio-ffmpeg (portable, needs imageio[ffmpeg])
1197
+ try:
1198
+ import imageio
1199
+ writer = imageio.get_writer(base + ".mp4", fps=float(fps), macro_block_size=None) # expects RGB frames
1200
+ return writer, base + ".mp4", "imageio-ffmpeg"
1201
+ except Exception:
1202
+ return None, None, None
1203
+
1204
+
1205
+
1206
+
1207
+ def _update_loaded_count_label(self, n: int):
1208
+ # pluralize nicely
1209
+ self.loading_label.setText(f"Loaded {n} image{'s' if n != 1 else ''}.")
1210
+
1211
+ def _apply_playback_interval(self, *_):
1212
+ # read from custom spin if present
1213
+ fps = float(self.speed_spin.value) if hasattr(self, "speed_spin") else float(getattr(self, "play_fps", 1.0))
1214
+ fps = max(0.1, min(10.0, fps))
1215
+ self.play_fps = fps
1216
+ self.playback_timer.setInterval(int(round(1000.0 / fps))) # 0.1 fps -> 10000 ms
1217
+
1218
+ def _on_current_item_changed_safe(self, current, previous):
1219
+ if not current:
1220
+ return
1221
+
1222
+ # If mouse is down, defer a bit, but DO NOT capture the item
1223
+ if QApplication.mouseButtons() != Qt.MouseButton.NoButton:
1224
+ QTimer.singleShot(120, self._center_if_no_mouse)
1225
+ return
1226
+
1227
+ # Defer to allow selection to settle, then ensure the *current* item is visible
1228
+ QTimer.singleShot(0, self._ensure_current_visible)
1229
+
1230
+ def _ensure_current_visible(self):
1231
+ item = self.fileTree.currentItem()
1232
+ if item is not None:
1233
+ self.fileTree.scrollToItem(item, QAbstractItemView.ScrollHint.EnsureVisible)
1234
+
1235
+ def _center_if_no_mouse(self):
1236
+ if QApplication.mouseButtons() == Qt.MouseButton.NoButton:
1237
+ item = self.fileTree.currentItem()
1238
+ if item is not None:
1239
+ self.fileTree.scrollToItem(item, QAbstractItemView.ScrollHint.EnsureVisible)
1240
+
1241
+ def toggle_aggressive(self):
1242
+ self.aggressive_stretch_enabled = self.aggressive_button.isChecked()
1243
+ # force a redisplay of the current image
1244
+ cur = self.fileTree.currentItem()
1245
+ if cur:
1246
+ self.on_item_clicked(cur, 0)
1247
+
1248
+ def clearFlags(self):
1249
+ """Clear all flagged states, update tree icons & metrics."""
1250
+ # 1) Reset internal flag state
1251
+ for entry in self.loaded_images:
1252
+ entry['flagged'] = False
1253
+
1254
+ # 2) Update tree widget: strip any "⚠️ " prefix and reset color
1255
+ normal = self.fileTree.palette().color(QPalette.ColorRole.WindowText)
1256
+ for item in self.get_all_leaf_items():
1257
+ name = item.text(0).lstrip("⚠️ ")
1258
+ item.setText(0, name)
1259
+ item.setForeground(0, QBrush(normal))
1260
+
1261
+ # 3) If metrics window is open, refresh its dots & status
1262
+ if self.metrics_window:
1263
+ panel = self.metrics_window.metrics_panel
1264
+ panel.flags = [False] * len(self.loaded_images)
1265
+ panel._refresh_scatter_colors()
1266
+ # update the "Flagged Items X/Y" label
1267
+ self.metrics_window._update_status()
1268
+
1269
+ # inside BlinkTab
1270
+ def _sync_metrics_flags(self):
1271
+ if self.metrics_window:
1272
+ panel = self.metrics_window.metrics_panel
1273
+ panel.flags = [entry['flagged'] for entry in self.loaded_images]
1274
+ panel._refresh_scatter_colors()
1275
+ # after a move/delete, current_indices might be stale → refresh text safely
1276
+ self.metrics_window._update_status()
1277
+
1278
+
1279
+ def addAdditionalImages(self):
1280
+ """Let the user pick more images to append to the blink list."""
1281
+ file_paths, _ = QFileDialog.getOpenFileNames(
1282
+ self,
1283
+ "Add Additional Images",
1284
+ "",
1285
+ "Images (*.png *.tif *.tiff *.fits *.fit *.xisf *.cr2 *.nef *.arw *.dng *.raf *.orf *.rw2 *.pef);;All Files (*)"
1286
+ )
1287
+ # filter out duplicates
1288
+ new_paths = [p for p in file_paths if p not in self.image_paths]
1289
+ if not new_paths:
1290
+ QMessageBox.information(self, "No New Images", "No new images selected or already loaded.")
1291
+ return
1292
+ self._appendImages(new_paths)
1293
+
1294
+ def _appendImages(self, file_paths):
1295
+ # decide dtype exactly as in loadImages
1296
+ mem = psutil.virtual_memory()
1297
+ avail = mem.available / (1024**3)
1298
+ if avail <= 16:
1299
+ target_dtype = np.uint8
1300
+ elif avail <= 32:
1301
+ target_dtype = np.uint16
1302
+ else:
1303
+ target_dtype = np.float32
1304
+
1305
+ total_new = len(file_paths)
1306
+ self.progress_bar.setRange(0, total_new)
1307
+ self.progress_bar.setValue(0)
1308
+ QApplication.processEvents()
1309
+
1310
+ # load one-by-one (or you could parallelize as you like)
1311
+ for i, path in enumerate(sorted(file_paths, key=lambda p: self._natural_key(os.path.basename(p)))):
1312
+ try:
1313
+ _, hdr, bit_depth, is_mono, stored, back = self._load_one_image(path, target_dtype)
1314
+ except Exception as e:
1315
+ print(f"Failed to load {path}: {e}")
1316
+ continue
1317
+
1318
+ # append to our master lists
1319
+ self.image_paths.append(path)
1320
+ self.loaded_images.append({
1321
+ 'file_path': path,
1322
+ 'image_data': stored,
1323
+ 'header': hdr or {},
1324
+ 'bit_depth': bit_depth,
1325
+ 'is_mono': is_mono,
1326
+ 'flagged': False,
1327
+ 'orig_background': back
1328
+ })
1329
+
1330
+ # update progress bar
1331
+ self.progress_bar.setValue(i+1)
1332
+ QApplication.processEvents()
1333
+
1334
+ # and add it into the tree under the correct object/filter/exp
1335
+ self.add_item_to_tree(path)
1336
+
1337
+ # update status
1338
+ self.loading_label.setText(f"Loaded {len(self.loaded_images)} images.")
1339
+ if self.metrics_window and self.metrics_window.isVisible():
1340
+ self.metrics_window.update_metrics(self.loaded_images, order=self._tree_order_indices())
1341
+
1342
+ self.imagesChanged.emit(len(self.loaded_images))
1343
+
1344
+ def show_metrics(self):
1345
+ if self.metrics_window is None:
1346
+ self.metrics_window = MetricsWindow()
1347
+ mp = self.metrics_window.metrics_panel
1348
+ mp.pointClicked.connect(self.on_metrics_point)
1349
+ mp.thresholdChanged.connect(self.on_threshold_change)
1350
+
1351
+ order = self._tree_order_indices()
1352
+ self.metrics_window.set_images(self.loaded_images, order=order)
1353
+ panel = self.metrics_window.metrics_panel
1354
+ self.thresholds_by_group["All"] = [line.value() for line in panel.lines]
1355
+ self.metrics_window.show()
1356
+ self.metrics_window.raise_()
1357
+
1358
+ def on_metrics_point(self, metric_idx, frame_idx):
1359
+ item = self.get_tree_item_for_index(frame_idx)
1360
+ if not item:
1361
+ return
1362
+ self._toggle_flag_on_item(item)
1363
+
1364
+ def _as_float01(self, arr):
1365
+ """Convert any stored dtype to float32 in [0..1], with safety normalization."""
1366
+ if arr.dtype == np.uint8:
1367
+ out = arr.astype(np.float32) / 255.0
1368
+ return out
1369
+
1370
+ if arr.dtype == np.uint16:
1371
+ out = arr.astype(np.float32) / 65535.0
1372
+ return out
1373
+
1374
+ # float path (or anything else): normalize if needed
1375
+ out = np.asarray(arr, dtype=np.float32)
1376
+
1377
+ if out.size == 0:
1378
+ return out
1379
+
1380
+ # handle NaNs/Infs early
1381
+ out = np.nan_to_num(out, nan=0.0, posinf=0.0, neginf=0.0)
1382
+
1383
+ mn = float(out.min())
1384
+ if mn < 0.0:
1385
+ out = out - mn # shift so min becomes 0
1386
+
1387
+ mx = float(out.max())
1388
+ if mx > 1.0 and mx > 0.0:
1389
+ out = out / mx # scale so max becomes 1
1390
+
1391
+ return np.clip(out, 0.0, 1.0)
1392
+
1393
+
1394
+
1395
+ def on_threshold_change(self, metric_idx, threshold):
1396
+ panel = self.metrics_window.metrics_panel
1397
+ if panel.metrics_data is None:
1398
+ return
1399
+
1400
+ # figure out which FILTER group we're in
1401
+ group = self.metrics_window.group_combo.currentText()
1402
+ # ensure we have a 4-slot list for this group
1403
+ thr_list = self.thresholds_by_group.setdefault(group, [None]*4)
1404
+ # store the new threshold for this metric
1405
+ thr_list[metric_idx] = threshold
1406
+
1407
+ # build the list of indices to re-evaluate
1408
+ if group == "All":
1409
+ indices = range(len(self.loaded_images))
1410
+ else:
1411
+ indices = [
1412
+ i for i, e in enumerate(self.loaded_images)
1413
+ if e.get('header', {}).get('FILTER','Unknown') == group
1414
+ ]
1415
+
1416
+ # re‐flag only those frames in this group, OR across all 4 metrics
1417
+ for i in indices:
1418
+ entry = self.loaded_images[i]
1419
+ flagged = False
1420
+ for m, thr in enumerate(thr_list):
1421
+ if thr is None:
1422
+ continue
1423
+ val = panel.metrics_data[m][i]
1424
+ if np.isnan(val):
1425
+ continue
1426
+ if (m < 3 and val > thr) or (m == 3 and val < thr):
1427
+ flagged = True
1428
+ break
1429
+ entry['flagged'] = flagged
1430
+
1431
+ # update the tree icon
1432
+ item = self.get_tree_item_for_index(i)
1433
+ if item:
1434
+ RED = Qt.GlobalColor.red
1435
+ normal = self.fileTree.palette().color(QPalette.ColorRole.WindowText)
1436
+ name = item.text(0).lstrip("⚠️ ")
1437
+ if flagged:
1438
+ item.setText(0, f"⚠️ {name}")
1439
+ item.setForeground(0, QBrush(RED))
1440
+ else:
1441
+ item.setText(0, name)
1442
+ item.setForeground(0, QBrush(normal))
1443
+
1444
+ # now push the *entire* up-to-date flagged list into the panel
1445
+ panel.flags = [e['flagged'] for e in self.loaded_images]
1446
+ panel._refresh_scatter_colors()
1447
+ self.metrics_window._update_status()
1448
+
1449
+ def _rebuild_tree_from_loaded(self):
1450
+ """Rebuild the left tree from self.loaded_images without reloading or recomputing."""
1451
+ self.fileTree.clear()
1452
+ from collections import defaultdict
1453
+ grouped = defaultdict(list)
1454
+ for entry in self.loaded_images:
1455
+ hdr = entry.get('header', {}) or {}
1456
+ obj = hdr.get('OBJECT', 'Unknown')
1457
+ fil = hdr.get('FILTER', 'Unknown')
1458
+ exp = hdr.get('EXPOSURE', 'Unknown')
1459
+ grouped[(obj, fil, exp)].append(entry['file_path'])
1460
+
1461
+ # natural sort within each leaf group
1462
+ for key, paths in grouped.items():
1463
+ paths.sort(key=lambda p: self._natural_key(os.path.basename(p)))
1464
+
1465
+ by_object = defaultdict(lambda: defaultdict(dict))
1466
+ for (obj, fil, exp), paths in grouped.items():
1467
+ by_object[obj][fil][exp] = paths
1468
+
1469
+ for obj in sorted(by_object, key=lambda o: o.lower()):
1470
+ obj_item = QTreeWidgetItem([f"Object: {obj}"])
1471
+ self.fileTree.addTopLevelItem(obj_item)
1472
+ obj_item.setExpanded(True)
1473
+ for fil in sorted(by_object[obj], key=lambda f: f.lower()):
1474
+ fil_item = QTreeWidgetItem([f"Filter: {fil}"])
1475
+ obj_item.addChild(fil_item)
1476
+ fil_item.setExpanded(True)
1477
+ for exp in sorted(by_object[obj][fil], key=lambda e: str(e).lower()):
1478
+ exp_item = QTreeWidgetItem([f"Exposure: {exp}"])
1479
+ fil_item.addChild(exp_item)
1480
+ exp_item.setExpanded(True)
1481
+ for p in by_object[obj][fil][exp]:
1482
+ leaf = QTreeWidgetItem([os.path.basename(p)])
1483
+ leaf.setData(0, Qt.ItemDataRole.UserRole, p)
1484
+ exp_item.addChild(leaf)
1485
+
1486
+ # 🔹 NEW: re-apply flagged styling
1487
+ RED = Qt.GlobalColor.red
1488
+ normal = self.fileTree.palette().color(QPalette.ColorRole.WindowText)
1489
+ for idx, entry in enumerate(self.loaded_images):
1490
+ item = self.get_tree_item_for_index(idx)
1491
+ if not item:
1492
+ continue
1493
+ base = os.path.basename(self.image_paths[idx])
1494
+ if entry.get("flagged", False):
1495
+ item.setText(0, f"⚠️ {base}")
1496
+ item.setForeground(0, QBrush(RED))
1497
+ else:
1498
+ item.setText(0, base)
1499
+ item.setForeground(0, QBrush(normal))
1500
+
1501
+
1502
+ def _after_list_changed(self, removed_indices: List[int] | None = None):
1503
+ """Call after you mutate image_paths/loaded_images. Keeps UI + metrics in sync w/o recompute."""
1504
+ # 1) rebuild the tree (groups collapse if empty)
1505
+ self._rebuild_tree_from_loaded()
1506
+ self.imagesChanged.emit(len(self.loaded_images))
1507
+
1508
+ # 2) refresh metrics (if open) WITHOUT recomputing SEP
1509
+ if self.metrics_window and self.metrics_window.isVisible():
1510
+ if removed_indices:
1511
+ # drop points and reindex
1512
+ self.metrics_window._all_images = self.loaded_images
1513
+ self.metrics_window.remove_indices(list(removed_indices))
1514
+ else:
1515
+ # just order changed or paths changed -> replot current group
1516
+ self.metrics_window.update_metrics(
1517
+ self.loaded_images,
1518
+ order=self._tree_order_indices()
1519
+ )
1520
+
1521
+ def get_tree_item_for_index(self, idx):
1522
+ target = os.path.basename(self.image_paths[idx])
1523
+ for item in self.get_all_leaf_items():
1524
+ if item.text(0).lstrip("⚠️ ") == target:
1525
+ return item
1526
+ return None
1527
+
1528
+ def compute_metric(self, metric_idx, entry):
1529
+ """Recompute a single metric for one image. Use cached orig_background for metric 2."""
1530
+ # metric 2 is the pre-stretch background we already computed
1531
+ if metric_idx == 2:
1532
+ return entry.get('orig_background', np.nan)
1533
+
1534
+ # otherwise rebuild a float32 [0..1] array from whatever dtype we stored
1535
+ img = entry['image_data']
1536
+ if img.dtype == np.uint8:
1537
+ data = img.astype(np.float32)/255.0
1538
+ elif img.dtype == np.uint16:
1539
+ data = img.astype(np.float32)/65535.0
1540
+ else:
1541
+ data = np.asarray(img, dtype=np.float32)
1542
+ if data.ndim == 3:
1543
+ data = data.mean(axis=2)
1544
+
1545
+ # run SEP for the other metrics
1546
+ bkg = sep.Background(data)
1547
+ back, gr, rr = bkg.back(), bkg.globalback, bkg.globalrms
1548
+ cat = sep.extract(data - back, 5.0, err=gr, minarea=9)
1549
+ if len(cat)==0:
1550
+ return np.nan
1551
+
1552
+ sig = np.sqrt(cat['a']*cat['b'])
1553
+ if metric_idx == 0:
1554
+ return np.nanmedian(2.3548*sig)
1555
+ elif metric_idx == 1:
1556
+ return np.nanmedian(1 - (cat['b']/cat['a']))
1557
+ else: # metric_idx == 3 (star count)
1558
+ return len(cat)
1559
+
1560
+
1561
+ def init_shortcuts(self):
1562
+ """Initialize keyboard shortcuts."""
1563
+ toggle_shortcut = QShortcut(QKeySequence("Space"), self.fileTree)
1564
+ def _toggle_play():
1565
+ if self.playback_timer.isActive():
1566
+ self.stop_playback()
1567
+ else:
1568
+ self.start_playback()
1569
+ toggle_shortcut.activated.connect(_toggle_play)
1570
+ # Create a shortcut for the "F" key to flag images
1571
+ flag_shortcut = QShortcut(QKeySequence("F"), self.fileTree)
1572
+ flag_shortcut.activated.connect(self.flag_current_image)
1573
+
1574
+ def openDirectoryDialog(self):
1575
+ """Allow users to select a directory and load all images within it recursively."""
1576
+ directory = QFileDialog.getExistingDirectory(self, "Select Directory", "")
1577
+ if directory:
1578
+ # Supported image extensions
1579
+ supported_extensions = (
1580
+ '.png', '.tif', '.tiff', '.fits', '.fit',
1581
+ '.xisf', '.cr2', '.nef', '.arw', '.dng', '.raf',
1582
+ '.orf', '.rw2', '.pef'
1583
+ )
1584
+
1585
+ # Collect all image file paths recursively
1586
+ new_file_paths = []
1587
+ for root, _, files in os.walk(directory):
1588
+ for file in sorted(files, key=str.lower): # 🔹 Sort alphabetically (case-insensitive)
1589
+ if file.lower().endswith(supported_extensions):
1590
+ full_path = os.path.join(root, file)
1591
+ if full_path not in self.image_paths: # Avoid duplicates
1592
+ new_file_paths.append(full_path)
1593
+
1594
+ if new_file_paths:
1595
+ self.loadImages(new_file_paths)
1596
+ else:
1597
+ QMessageBox.information(self, "No Images Found", "No supported image files were found in the selected directory.")
1598
+
1599
+
1600
+ def clearImages(self):
1601
+ """Clear all loaded images and reset the tree view."""
1602
+ confirmation = QMessageBox.question(
1603
+ self,
1604
+ "Clear All Images",
1605
+ "Are you sure you want to clear all loaded images?",
1606
+ QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
1607
+ QMessageBox.StandardButton.No
1608
+ )
1609
+ if confirmation == QMessageBox.StandardButton.Yes:
1610
+ self.stop_playback()
1611
+ self.image_paths.clear()
1612
+ self.loaded_images.clear()
1613
+ self.image_labels.clear()
1614
+ self.fileTree.clear()
1615
+ self.preview_label.clear()
1616
+ self.preview_label.setText('No image selected.')
1617
+ self.current_pixmap = None
1618
+ self.progress_bar.setValue(0)
1619
+ self.loading_label.setText("Loading images...")
1620
+ self.imagesChanged.emit(len(self.loaded_images))
1621
+
1622
+ # (legacy) if you still have this, you can delete it:
1623
+ # self.thresholds = [None, None, None, None]
1624
+
1625
+ # also reset the metrics panel (if it’s open)
1626
+ if self.metrics_window is not None:
1627
+ mp = self.metrics_window.metrics_panel
1628
+ # clear out old data & reset flags / thresholds
1629
+ mp.metrics_data = None
1630
+ mp._threshold_initialized = [False]*4
1631
+ for scat in mp.scats:
1632
+ scat.clear()
1633
+ for line in mp.lines:
1634
+ line.setPos(0)
1635
+
1636
+ # clear per‐group threshold storage
1637
+ self.metrics_window._thresholds_per_group.clear()
1638
+
1639
+ # finally, tell the MetricsWindow to fully re‐init with no images
1640
+ if self.metrics_window is not None:
1641
+ self.metrics_window.update_metrics([])
1642
+
1643
+
1644
+
1645
+ @staticmethod
1646
+ def _load_one_image(file_path: str, target_dtype):
1647
+ """Load + pre-process one image & return all metadata."""
1648
+
1649
+ # 1) load
1650
+ image, header, bit_depth, is_mono = load_image(file_path)
1651
+ if image is None or image.size == 0:
1652
+ raise ValueError("Empty image")
1653
+
1654
+ # 2) optional debayer
1655
+ if is_mono:
1656
+ # adjust this call to match your debayer signature
1657
+ image = BlinkTab.debayer_image(image, file_path, header)
1658
+
1659
+ # ✅ NEW: force 0..1 range BEFORE SEP + stretch
1660
+ image = BlinkTab._ensure_float01(image)
1661
+
1662
+ # 3) SEP background on mono float32
1663
+ data = np.asarray(image, dtype=np.float32, order='C')
1664
+ if data.ndim == 3:
1665
+ data = data.mean(axis=2)
1666
+ bkg = sep.Background(data)
1667
+ global_back = bkg.globalback
1668
+
1669
+ # 4) stretch
1670
+ target_med = 0.25
1671
+ if image.ndim == 2:
1672
+ stretched = stretch_mono_image(image, target_med)
1673
+ else:
1674
+ stretched = stretch_color_image(image, target_med, linked=False)
1675
+
1676
+ # 5) cast to target_dtype
1677
+ clipped = np.clip(stretched, 0.0, 1.0)
1678
+ if target_dtype is np.uint8:
1679
+ stored = (clipped * 255).astype(np.uint8)
1680
+ elif target_dtype is np.uint16:
1681
+ stored = (clipped * 65535).astype(np.uint16)
1682
+ else:
1683
+ stored = clipped.astype(np.float32)
1684
+
1685
+ return file_path, header, bit_depth, is_mono, stored, global_back
1686
+
1687
+ @staticmethod
1688
+ def debayer_image(image, file_path, header):
1689
+ """Check if image is OSC (One-Shot Color) and debayer if required."""
1690
+ if file_path.lower().endswith(('.fits', '.fit')):
1691
+ bayer_pattern = header.get('BAYERPAT', None)
1692
+ if bayer_pattern:
1693
+ image = debayer_fits_fast(image, bayer_pattern)
1694
+ elif file_path.lower().endswith(('.cr2', '.nef', '.arw', '.dng', '.raf', '.orf', '.rw2', '.pef')):
1695
+ image = debayer_raw_fast(image, bayer_pattern="RGGB")
1696
+ return image
1697
+
1698
+ @staticmethod
1699
+ def _natural_key(path: str):
1700
+ """
1701
+ Split a filename into text and integer chunks so that
1702
+ “…_2.fit” sorts before “…_10.fit”.
1703
+ """
1704
+ name = os.path.basename(path)
1705
+ return [int(tok) if tok.isdigit() else tok.lower()
1706
+ for tok in re.split(r'(\d+)', name)]
1707
+
1708
+ def loadImages(self, file_paths):
1709
+ # 0) early out
1710
+ if not file_paths:
1711
+ return
1712
+
1713
+ # ---------- NEW: natural sort the list of filenames ----------
1714
+ file_paths = sorted(file_paths, key=lambda p: self._natural_key(os.path.basename(p)))
1715
+
1716
+ # 1) pick dtype based on RAM
1717
+ mem = psutil.virtual_memory()
1718
+ avail = mem.available / (1024**3)
1719
+ if avail <= 16:
1720
+ target_dtype = np.uint8
1721
+ elif avail <= 32:
1722
+ target_dtype = np.uint16
1723
+ else:
1724
+ target_dtype = np.float32
1725
+
1726
+ total = len(file_paths)
1727
+ self.progress_bar.setRange(0, 100)
1728
+ self.progress_bar.setValue(0)
1729
+ QApplication.processEvents()
1730
+
1731
+ self.image_paths.clear()
1732
+ self.loaded_images.clear()
1733
+ self.fileTree.clear()
1734
+
1735
+ # ---------- NEW: Retry-aware parallel load ----------
1736
+ MAX_RETRIES = 2
1737
+ RETRY_DELAY = 2
1738
+ remaining = list(file_paths)
1739
+ completed = []
1740
+ attempt = 0
1741
+
1742
+ while remaining and attempt <= MAX_RETRIES:
1743
+
1744
+ total_cpus = os.cpu_count() or 1
1745
+ reserved_cpus = min(4, max(1, int(total_cpus * 0.25)))
1746
+ max_workers = max(1, min(total_cpus - reserved_cpus, 60))
1747
+
1748
+ futures = {}
1749
+ failed = []
1750
+
1751
+ with ThreadPoolExecutor(max_workers=max_workers) as executor:
1752
+ for path in remaining:
1753
+ futures[executor.submit(self._load_one_image, path, target_dtype)] = path
1754
+ for fut in as_completed(futures):
1755
+ path = futures[fut]
1756
+ try:
1757
+ result = fut.result()
1758
+ completed.append(result)
1759
+ done = len(completed)
1760
+ self.progress_bar.setValue(int(100 * done / total))
1761
+ QApplication.processEvents()
1762
+ except Exception as e:
1763
+ print(f"[WARN][Attempt {attempt}] Failed to load {path}: {e}")
1764
+ failed.append(path)
1765
+
1766
+ remaining = failed
1767
+ attempt += 1
1768
+ if remaining:
1769
+ print(f"[Retry] {len(remaining)} images will be retried after {RETRY_DELAY}s...")
1770
+ time.sleep(RETRY_DELAY)
1771
+
1772
+ if remaining:
1773
+ print(f"[FAILURE] These files failed to load after {MAX_RETRIES} retries:")
1774
+ for path in remaining:
1775
+ print(f" - {path}")
1776
+
1777
+ # ---------- Unpack completed results ----------
1778
+ for path, header, bit_depth, is_mono, stored, back in completed:
1779
+ header = header or {}
1780
+ self.image_paths.append(path)
1781
+ self.loaded_images.append({
1782
+ 'file_path': path,
1783
+ 'image_data': stored,
1784
+ 'header': header,
1785
+ 'bit_depth': bit_depth,
1786
+ 'is_mono': is_mono,
1787
+ 'flagged': False,
1788
+ 'orig_background': back
1789
+ })
1790
+
1791
+ # 3) rebuild object/filter/exposure tree
1792
+ grouped = defaultdict(list)
1793
+ for entry in self.loaded_images:
1794
+ hdr = entry['header']
1795
+ obj = hdr.get('OBJECT', 'Unknown')
1796
+ filt = hdr.get('FILTER', 'Unknown')
1797
+ exp = hdr.get('EXPOSURE', 'Unknown')
1798
+ grouped[(obj, filt, exp)].append(entry['file_path'])
1799
+
1800
+ for key, paths in grouped.items():
1801
+ paths.sort(key=lambda p: self._natural_key(os.path.basename(p)))
1802
+ by_object = defaultdict(lambda: defaultdict(dict))
1803
+ for (obj, filt, exp), paths in grouped.items():
1804
+ by_object[obj][filt][exp] = paths
1805
+
1806
+ for obj in sorted(by_object, key=lambda o: o.lower()):
1807
+ obj_item = QTreeWidgetItem([f"Object: {obj}"])
1808
+ self.fileTree.addTopLevelItem(obj_item)
1809
+ obj_item.setExpanded(True)
1810
+
1811
+ for filt in sorted(by_object[obj], key=lambda f: f.lower()):
1812
+ filt_item = QTreeWidgetItem([f"Filter: {filt}"])
1813
+ obj_item.addChild(filt_item)
1814
+ filt_item.setExpanded(True)
1815
+
1816
+ for exp in sorted(by_object[obj][filt], key=lambda e: str(e).lower()):
1817
+ exp_item = QTreeWidgetItem([f"Exposure: {exp}"])
1818
+ filt_item.addChild(exp_item)
1819
+ exp_item.setExpanded(True)
1820
+
1821
+ for p in by_object[obj][filt][exp]:
1822
+ leaf = QTreeWidgetItem([os.path.basename(p)])
1823
+ leaf.setData(0, Qt.ItemDataRole.UserRole, p)
1824
+ exp_item.addChild(leaf)
1825
+
1826
+ self.loading_label.setText(f"Loaded {len(self.loaded_images)} images.")
1827
+ self.progress_bar.setValue(100)
1828
+ self.imagesChanged.emit(len(self.loaded_images))
1829
+ if self.metrics_window and self.metrics_window.isVisible():
1830
+ self.metrics_window.update_metrics(self.loaded_images, order=self._tree_order_indices())
1831
+
1832
+
1833
+ def findTopLevelItemByName(self, name):
1834
+ """Find a top-level item in the tree by its name."""
1835
+ for index in range(self.fileTree.topLevelItemCount()):
1836
+ item = self.fileTree.topLevelItem(index)
1837
+ if item.text(0) == name:
1838
+ return item
1839
+ return None
1840
+
1841
+ def findChildItemByName(self, parent, name):
1842
+ """Find a child item under a given parent by its name."""
1843
+ for index in range(parent.childCount()):
1844
+ child = parent.child(index)
1845
+ if child.text(0) == name:
1846
+ return child
1847
+ return None
1848
+
1849
+
1850
+ def _toggle_flag_on_item(self, item: QTreeWidgetItem, *, sync_metrics: bool = True):
1851
+ file_name = item.text(0).lstrip("⚠️ ")
1852
+ file_path = next((p for p in self.image_paths if os.path.basename(p) == file_name), None)
1853
+ if file_path is None:
1854
+ return
1855
+
1856
+ idx = self.image_paths.index(file_path)
1857
+ entry = self.loaded_images[idx]
1858
+ entry['flagged'] = not entry['flagged']
1859
+
1860
+ RED = Qt.GlobalColor.red
1861
+ palette = self.fileTree.palette()
1862
+ normal_color = palette.color(QPalette.ColorRole.WindowText)
1863
+
1864
+ if entry['flagged']:
1865
+ item.setText(0, f"⚠️ {file_name}")
1866
+ item.setForeground(0, QBrush(RED))
1867
+ else:
1868
+ item.setText(0, file_name)
1869
+ item.setForeground(0, QBrush(normal_color))
1870
+
1871
+ if sync_metrics:
1872
+ self._sync_metrics_flags()
1873
+
1874
+ def flag_current_image(self):
1875
+ item = self.fileTree.currentItem()
1876
+ if not item:
1877
+ QMessageBox.warning(self, "No Selection", "No image is currently selected to flag.")
1878
+ return
1879
+ self._toggle_flag_on_item(item) # ← this now updates the metrics panel too
1880
+ self.next_item()
1881
+
1882
+
1883
+ def on_current_item_changed(self, current, previous):
1884
+ """Ensure the selected item is visible by scrolling to it."""
1885
+ if current:
1886
+ self.fileTree.scrollToItem(current, QAbstractItemView.ScrollHint.PositionAtCenter)
1887
+
1888
+ def previous_item(self):
1889
+ """Select the previous item in the TreeWidget."""
1890
+ current_item = self.fileTree.currentItem()
1891
+ if current_item:
1892
+ all_items = self.get_all_leaf_items()
1893
+ current_index = all_items.index(current_item)
1894
+ if current_index > 0:
1895
+ previous_item = all_items[current_index - 1]
1896
+ else:
1897
+ previous_item = all_items[-1] # Loop back to the last item
1898
+ self.fileTree.setCurrentItem(previous_item)
1899
+ #self.on_item_clicked(previous_item, 0) # Update the preview
1900
+
1901
+ def next_item(self):
1902
+ """Select the next item in the TreeWidget, looping back to the first item if at the end."""
1903
+ current_item = self.fileTree.currentItem()
1904
+ if current_item:
1905
+ # Get all leaf items
1906
+ all_items = self.get_all_leaf_items()
1907
+
1908
+ # Check if the current item is in the leaf items
1909
+ try:
1910
+ current_index = all_items.index(current_item)
1911
+ except ValueError:
1912
+ # If the current item is not a leaf, move to the first leaf item
1913
+ print("Current item is not a leaf. Selecting the first leaf item.")
1914
+ if all_items:
1915
+ next_item = all_items[0]
1916
+ self.fileTree.setCurrentItem(next_item)
1917
+ self.on_item_clicked(next_item, 0)
1918
+ return
1919
+
1920
+ # Select the next leaf item or loop back to the first
1921
+ if current_index < len(all_items) - 1:
1922
+ next_item = all_items[current_index + 1]
1923
+ else:
1924
+ next_item = all_items[0] # Loop back to the first item
1925
+
1926
+ self.fileTree.setCurrentItem(next_item)
1927
+ #self.on_item_clicked(next_item, 0) # Update the preview
1928
+ else:
1929
+ print("No current item selected.")
1930
+
1931
+ def get_all_leaf_items(self):
1932
+ """Get a flat list of all leaf items (actual files) in the TreeWidget."""
1933
+ def recurse(parent):
1934
+ items = []
1935
+ for index in range(parent.childCount()):
1936
+ child = parent.child(index)
1937
+ if child.childCount() == 0: # It's a leaf item
1938
+ items.append(child)
1939
+ else:
1940
+ items.extend(recurse(child))
1941
+ return items
1942
+
1943
+ root = self.fileTree.invisibleRootItem()
1944
+ return recurse(root)
1945
+
1946
+ def start_playback(self):
1947
+ """Start playing through the items in the TreeWidget."""
1948
+ if self.playback_timer.isActive():
1949
+ return
1950
+
1951
+ leaves = self.get_all_leaf_items()
1952
+ if not leaves:
1953
+ QMessageBox.information(self, "No Images", "Load some images first.")
1954
+ return
1955
+
1956
+ # Ensure a current leaf item is selected
1957
+ cur = self.fileTree.currentItem()
1958
+ if cur is None or cur.childCount() > 0:
1959
+ self.fileTree.setCurrentItem(leaves[0])
1960
+
1961
+ # Honor current fps setting
1962
+ self._apply_playback_interval()
1963
+ self.playback_timer.start()
1964
+
1965
+ def stop_playback(self):
1966
+ """Stop playing through the items."""
1967
+ if self.playback_timer.isActive():
1968
+ self.playback_timer.stop()
1969
+
1970
+
1971
+ def openFileDialog(self):
1972
+ """Allow users to select multiple images and add them to the existing list."""
1973
+ file_paths, _ = QFileDialog.getOpenFileNames(
1974
+ self,
1975
+ "Open Images",
1976
+ "",
1977
+ "Images (*.png *.tif *.tiff *.fits *.fit *.xisf *.cr2 *.cr3 *.nef *.arw *.dng *.raf *.orf *.rw2 *.pef);;All Files (*)"
1978
+ )
1979
+
1980
+ # Filter out already loaded images to prevent duplicates
1981
+ new_file_paths = [path for path in file_paths if path not in self.image_paths]
1982
+
1983
+ if new_file_paths:
1984
+ self.loadImages(new_file_paths)
1985
+ else:
1986
+ QMessageBox.information(self, "No New Images", "No new images were selected or all selected images are already loaded.")
1987
+
1988
+
1989
+ def debayer_fits(self, image_data, bayer_pattern):
1990
+ """Debayer a FITS image using a basic Bayer pattern (2x2)."""
1991
+ if bayer_pattern == 'RGGB':
1992
+ # RGGB Bayer pattern
1993
+ r = image_data[::2, ::2] # Red
1994
+ g1 = image_data[::2, 1::2] # Green 1
1995
+ g2 = image_data[1::2, ::2] # Green 2
1996
+ b = image_data[1::2, 1::2] # Blue
1997
+
1998
+ # Average green channels
1999
+ g = (g1 + g2) / 2
2000
+ return np.stack([r, g, b], axis=-1)
2001
+
2002
+ elif bayer_pattern == 'BGGR':
2003
+ # BGGR Bayer pattern
2004
+ b = image_data[::2, ::2] # Blue
2005
+ g1 = image_data[::2, 1::2] # Green 1
2006
+ g2 = image_data[1::2, ::2] # Green 2
2007
+ r = image_data[1::2, 1::2] # Red
2008
+
2009
+ # Average green channels
2010
+ g = (g1 + g2) / 2
2011
+ return np.stack([r, g, b], axis=-1)
2012
+
2013
+ elif bayer_pattern == 'GRBG':
2014
+ # GRBG Bayer pattern
2015
+ g1 = image_data[::2, ::2] # Green 1
2016
+ r = image_data[::2, 1::2] # Red
2017
+ b = image_data[1::2, ::2] # Blue
2018
+ g2 = image_data[1::2, 1::2] # Green 2
2019
+
2020
+ # Average green channels
2021
+ g = (g1 + g2) / 2
2022
+ return np.stack([r, g, b], axis=-1)
2023
+
2024
+ elif bayer_pattern == 'GBRG':
2025
+ # GBRG Bayer pattern
2026
+ g1 = image_data[::2, ::2] # Green 1
2027
+ b = image_data[::2, 1::2] # Blue
2028
+ r = image_data[1::2, ::2] # Red
2029
+ g2 = image_data[1::2, 1::2] # Green 2
2030
+
2031
+ # Average green channels
2032
+ g = (g1 + g2) / 2
2033
+ return np.stack([r, g, b], axis=-1)
2034
+
2035
+ else:
2036
+ raise ValueError(f"Unsupported Bayer pattern: {bayer_pattern}")
2037
+
2038
+ def remove_item_from_tree(self, file_path):
2039
+ """Remove a specific item from the tree view based on file path."""
2040
+ file_name = os.path.basename(file_path)
2041
+ root = self.fileTree.invisibleRootItem()
2042
+
2043
+ def recurse(parent):
2044
+ for index in range(parent.childCount()):
2045
+ child = parent.child(index)
2046
+ if child.text(0).endswith(file_name):
2047
+ parent.removeChild(child)
2048
+ return True
2049
+ if recurse(child):
2050
+ return True
2051
+ return False
2052
+
2053
+ recurse(root)
2054
+
2055
+ def add_item_to_tree(self, file_path):
2056
+ """Add a specific item to the tree view based on file path."""
2057
+ # Extract metadata for grouping
2058
+ image_entry = next((img for img in self.loaded_images if img['file_path'] == file_path), None)
2059
+ if not image_entry:
2060
+ return
2061
+
2062
+ header = image_entry['header']
2063
+ object_name = header.get('OBJECT', 'Unknown') if header else 'Unknown'
2064
+ filter_name = header.get('FILTER', 'Unknown') if header else 'Unknown'
2065
+ exposure_time = header.get('EXPOSURE', 'Unknown') if header else 'Unknown'
2066
+
2067
+ # Group images by filter and exposure time
2068
+ group_key = (object_name, filter_name, exposure_time)
2069
+
2070
+ # Find or create the object item
2071
+ object_item = self.findTopLevelItemByName(f"Object: {object_name}")
2072
+ if not object_item:
2073
+ object_item = QTreeWidgetItem([f"Object: {object_name}"])
2074
+ self.fileTree.addTopLevelItem(object_item)
2075
+ object_item.setExpanded(True)
2076
+
2077
+ # Find or create the filter item
2078
+ filter_item = self.findChildItemByName(object_item, f"Filter: {filter_name}")
2079
+ if not filter_item:
2080
+ filter_item = QTreeWidgetItem([f"Filter: {filter_name}"])
2081
+ object_item.addChild(filter_item)
2082
+ filter_item.setExpanded(True)
2083
+
2084
+ # Find or create the exposure item
2085
+ exposure_item = self.findChildItemByName(filter_item, f"Exposure: {exposure_time}")
2086
+ if not exposure_item:
2087
+ exposure_item = QTreeWidgetItem([f"Exposure: {exposure_time}"])
2088
+ filter_item.addChild(exposure_item)
2089
+ exposure_item.setExpanded(True)
2090
+
2091
+ # Add the file item
2092
+ file_name = os.path.basename(file_path)
2093
+ item = QTreeWidgetItem([file_name])
2094
+ item.setData(0, Qt.ItemDataRole.UserRole, file_path)
2095
+ exposure_item.addChild(item)
2096
+
2097
+ def _tree_order_indices(self) -> list[int]:
2098
+ """Return the indices of loaded_images in the exact order the Tree shows."""
2099
+ order = []
2100
+ for leaf in self.get_all_leaf_items():
2101
+ path = leaf.data(0, Qt.ItemDataRole.UserRole)
2102
+ if not path:
2103
+ # fallback by basename if old items exist
2104
+ name = leaf.text(0).lstrip("⚠️ ").strip()
2105
+ path = next((p for p in self.image_paths if os.path.basename(p) == name), None)
2106
+ if path and path in self.image_paths:
2107
+ order.append(self.image_paths.index(path))
2108
+ return order
2109
+
2110
+ def debayer_raw(self, raw_image_data, bayer_pattern="RGGB"):
2111
+ """Debayer a RAW image based on the Bayer pattern, ensuring even dimensions."""
2112
+ H, W = raw_image_data.shape
2113
+ # Crop to even dimensions if necessary
2114
+ if H % 2 != 0:
2115
+ raw_image_data = raw_image_data[:H-1, :]
2116
+ if W % 2 != 0:
2117
+ raw_image_data = raw_image_data[:, :W-1]
2118
+
2119
+ if bayer_pattern == 'RGGB':
2120
+ r = raw_image_data[::2, ::2] # Red
2121
+ g1 = raw_image_data[::2, 1::2] # Green 1
2122
+ g2 = raw_image_data[1::2, ::2] # Green 2
2123
+ b = raw_image_data[1::2, 1::2] # Blue
2124
+
2125
+ # Average green channels
2126
+ g = (g1 + g2) / 2
2127
+ return np.stack([r, g, b], axis=-1)
2128
+ elif bayer_pattern == 'BGGR':
2129
+ b = raw_image_data[::2, ::2] # Blue
2130
+ g1 = raw_image_data[::2, 1::2] # Green 1
2131
+ g2 = raw_image_data[1::2, ::2] # Green 2
2132
+ r = raw_image_data[1::2, 1::2] # Red
2133
+
2134
+ g = (g1 + g2) / 2
2135
+ return np.stack([r, g, b], axis=-1)
2136
+ elif bayer_pattern == 'GRBG':
2137
+ g1 = raw_image_data[::2, ::2] # Green 1
2138
+ r = raw_image_data[::2, 1::2] # Red
2139
+ b = raw_image_data[1::2, ::2] # Blue
2140
+ g2 = raw_image_data[1::2, 1::2] # Green 2
2141
+
2142
+ g = (g1 + g2) / 2
2143
+ return np.stack([r, g, b], axis=-1)
2144
+ elif bayer_pattern == 'GBRG':
2145
+ g1 = raw_image_data[::2, ::2] # Green 1
2146
+ b = raw_image_data[::2, 1::2] # Blue
2147
+ r = raw_image_data[1::2, ::2] # Red
2148
+ g2 = raw_image_data[1::2, 1::2] # Green 2
2149
+
2150
+ g = (g1 + g2) / 2
2151
+ return np.stack([r, g, b], axis=-1)
2152
+ else:
2153
+ raise ValueError(f"Unsupported Bayer pattern: {bayer_pattern}")
2154
+
2155
+
2156
+
2157
+ def on_item_clicked(self, item, column):
2158
+ self.fileTree.setFocus()
2159
+
2160
+ name = item.text(0).lstrip("⚠️ ").strip()
2161
+ file_path = next((p for p in self.image_paths if os.path.basename(p) == name), None)
2162
+ if not file_path:
2163
+ return
2164
+
2165
+ self._capture_view_center_norm()
2166
+
2167
+ idx = self.image_paths.index(file_path)
2168
+ entry = self.loaded_images[idx]
2169
+ stored = entry['image_data'] # already stretched & clipped at load time
2170
+
2171
+ # --- Fast path: just display what we cached in RAM ---
2172
+ if not self.aggressive_stretch_enabled:
2173
+ # Convert to 8-bit only if needed (no additional stretch)
2174
+ if stored.dtype == np.uint8:
2175
+ disp8 = stored
2176
+ elif stored.dtype == np.uint16:
2177
+ disp8 = (stored >> 8).astype(np.uint8) # ~ /257, quick & vectorized
2178
+ else: # float32 in [0..1]
2179
+ disp8 = (np.clip(stored, 0.0, 1.0) * 255.0).astype(np.uint8)
2180
+
2181
+ else:
2182
+ # Aggressive mode: compute only here (from float01)
2183
+ base01 = self._as_float01(stored)
2184
+ # Siril-style autostretch
2185
+ if base01.ndim == 2:
2186
+ st = siril_style_autostretch(base01, sigma=self.current_sigma)
2187
+ disp01 = self._as_float01(st) # <-- IMPORTANT: handles 0..255 or 0..1 correctly
2188
+ else:
2189
+ base01 = self._as_float01(stored)
2190
+
2191
+ if base01.ndim == 2:
2192
+ disp01 = self._aggressive_display_boost(base01, strength=self.current_sigma)
2193
+ else:
2194
+ lum = base01.mean(axis=2).astype(np.float32)
2195
+ lum_boost = self._aggressive_display_boost(lum, strength=self.current_sigma)
2196
+ gain = lum_boost / (lum + 1e-6)
2197
+ disp01 = np.clip(base01 * gain[..., None], 0.0, 1.0)
2198
+
2199
+ disp8 = (disp01 * 255.0).astype(np.uint8)
2200
+
2201
+
2202
+ qimage = self.convert_to_qimage(disp8)
2203
+ self.current_pixmap = QPixmap.fromImage(qimage)
2204
+ self.apply_zoom()
2205
+
2206
+ def _capture_view_center_norm(self):
2207
+ """Remember the current viewport center as a fraction of the content size."""
2208
+ sa = self.scroll_area
2209
+ vp = sa.viewport()
2210
+ content_w = max(1, self.preview_label.width())
2211
+ content_h = max(1, self.preview_label.height())
2212
+ if content_w <= 1 or content_h <= 1:
2213
+ return
2214
+ hbar = sa.horizontalScrollBar()
2215
+ vbar = sa.verticalScrollBar()
2216
+ cx = hbar.value() + vp.width() / 2.0
2217
+ cy = vbar.value() + vp.height() / 2.0
2218
+ self._view_center_norm = (cx / content_w, cy / content_h)
2219
+
2220
+ def _restore_view_center_norm(self):
2221
+ """Restore the viewport center captured earlier (if any)."""
2222
+ if not self._view_center_norm:
2223
+ return
2224
+ sa = self.scroll_area
2225
+ vp = sa.viewport()
2226
+ content_w = max(1, self.preview_label.width())
2227
+ content_h = max(1, self.preview_label.height())
2228
+ cx = self._view_center_norm[0] * content_w
2229
+ cy = self._view_center_norm[1] * content_h
2230
+ hbar = sa.horizontalScrollBar()
2231
+ vbar = sa.verticalScrollBar()
2232
+ h_target = int(round(cx - vp.width() / 2.0))
2233
+ v_target = int(round(cy - vp.height() / 2.0))
2234
+ h_target = max(hbar.minimum(), min(hbar.maximum(), h_target))
2235
+ v_target = max(vbar.minimum(), min(vbar.maximum(), v_target))
2236
+ # Set after layout settles to avoid fighting size changes
2237
+ QTimer.singleShot(0, lambda: (hbar.setValue(h_target), vbar.setValue(v_target)))
2238
+
2239
+ def apply_zoom(self):
2240
+ """Apply current zoom to pixmap without losing scroll position."""
2241
+ if not self.current_pixmap:
2242
+ return
2243
+
2244
+ # keep current center if we already showed something
2245
+ had_content = (self.preview_label.pixmap() is not None) and (self.preview_label.width() > 0)
2246
+
2247
+ if had_content:
2248
+ self._capture_view_center_norm()
2249
+ else:
2250
+ # first time: default center
2251
+ self._view_center_norm = (0.5, 0.5)
2252
+
2253
+ # scale and show
2254
+ base_w = self.current_pixmap.width()
2255
+ base_h = self.current_pixmap.height()
2256
+ scaled_w = max(1, int(round(base_w * self.zoom_level)))
2257
+ scaled_h = max(1, int(round(base_h * self.zoom_level)))
2258
+
2259
+ scaled = self.current_pixmap.scaled(
2260
+ scaled_w, scaled_h,
2261
+ Qt.AspectRatioMode.KeepAspectRatio,
2262
+ Qt.TransformationMode.SmoothTransformation,
2263
+ )
2264
+ self.preview_label.setPixmap(scaled)
2265
+ self.preview_label.resize(scaled.size())
2266
+
2267
+ # restore the center we captured (or 0.5,0.5 for first time)
2268
+ self._restore_view_center_norm()
2269
+
2270
+ def wheelEvent(self, event: QWheelEvent):
2271
+ # Check the vertical delta to determine zoom direction.
2272
+ if event.angleDelta().y() > 0:
2273
+ self.zoom_in()
2274
+ else:
2275
+ self.zoom_out()
2276
+ # Accept the event so it isn’t propagated further (e.g. to the scroll area).
2277
+ event.accept()
2278
+
2279
+
2280
+ def zoom_in(self):
2281
+ """Increase the zoom level and refresh the image."""
2282
+ self.zoom_level = min(self.zoom_level * 1.2, 3.0) # Cap at 3x
2283
+ self.apply_zoom()
2284
+
2285
+
2286
+ def zoom_out(self):
2287
+ """Decrease the zoom level and refresh the image."""
2288
+ self.zoom_level = max(self.zoom_level / 1.2, 0.05) # Cap at 0.2x
2289
+ self.apply_zoom()
2290
+
2291
+
2292
+ def fit_to_preview(self):
2293
+ """Adjust the zoom level so the image fits within the QScrollArea viewport."""
2294
+ if self.current_pixmap:
2295
+ # Get the size of the QScrollArea's viewport
2296
+ viewport_size = self.scroll_area.viewport().size()
2297
+ pixmap_size = self.current_pixmap.size()
2298
+
2299
+ # Calculate the zoom level required to fit the pixmap in the QScrollArea viewport
2300
+ width_ratio = viewport_size.width() / pixmap_size.width()
2301
+ height_ratio = viewport_size.height() / pixmap_size.height()
2302
+ self.zoom_level = min(width_ratio, height_ratio)
2303
+
2304
+ # Apply the zoom level
2305
+ self.apply_zoom()
2306
+ else:
2307
+ print("No image loaded. Cannot fit to preview.")
2308
+ QMessageBox.warning(self, "Warning", "No image loaded. Cannot fit to preview.")
2309
+
2310
+ def _is_leaf(self, item: Optional[QTreeWidgetItem]) -> bool:
2311
+ return bool(item and item.childCount() == 0)
2312
+
2313
+ def on_right_click(self, pos):
2314
+ item = self.fileTree.itemAt(pos)
2315
+ if not self._is_leaf(item):
2316
+ # Optional: expand/collapse-only menu, or just ignore
2317
+ return
2318
+
2319
+ menu = QMenu(self)
2320
+
2321
+ push_action = QAction("Open in Document Window", self)
2322
+ push_action.triggered.connect(lambda: self.push_to_docs(item))
2323
+ menu.addAction(push_action)
2324
+
2325
+ rename_action = QAction("Rename", self)
2326
+ rename_action.triggered.connect(lambda: self.rename_item(item))
2327
+ menu.addAction(rename_action)
2328
+
2329
+ # 🔹 NEW: batch rename selected
2330
+ batch_rename_action = QAction("Batch Rename Selected…", self)
2331
+ batch_rename_action.triggered.connect(self.batch_rename_items)
2332
+ menu.addAction(batch_rename_action)
2333
+
2334
+ move_action = QAction("Move Selected Items", self)
2335
+ move_action.triggered.connect(self.move_items)
2336
+ menu.addAction(move_action)
2337
+
2338
+ delete_action = QAction("Delete Selected Items", self)
2339
+ delete_action.triggered.connect(self.delete_items)
2340
+ menu.addAction(delete_action)
2341
+
2342
+ menu.addSeparator()
2343
+
2344
+ batch_delete_action = QAction("Delete All Flagged Images", self)
2345
+ batch_delete_action.triggered.connect(self.batch_delete_flagged_images)
2346
+ menu.addAction(batch_delete_action)
2347
+
2348
+ batch_move_action = QAction("Move All Flagged Images", self)
2349
+ batch_move_action.triggered.connect(self.batch_move_flagged_images)
2350
+ menu.addAction(batch_move_action)
2351
+
2352
+ # 🔹 NEW: rename all flagged images
2353
+ rename_flagged_action = QAction("Rename Flagged Images…", self)
2354
+ rename_flagged_action.triggered.connect(self.rename_flagged_images)
2355
+ menu.addAction(rename_flagged_action)
2356
+
2357
+ menu.addSeparator()
2358
+
2359
+ send_lights_act = QAction("Send to Stacking → Lights", self)
2360
+ send_lights_act.triggered.connect(self._send_to_stacking_lights)
2361
+ menu.addAction(send_lights_act)
2362
+
2363
+ send_integ_act = QAction("Send to Stacking → Integration", self)
2364
+ send_integ_act.triggered.connect(self._send_to_stacking_integration)
2365
+ menu.addAction(send_integ_act)
2366
+
2367
+ menu.exec(self.fileTree.mapToGlobal(pos))
2368
+
2369
+
2370
+ def push_to_docs(self, item):
2371
+ # Resolve file + entry
2372
+ file_name = item.text(0).lstrip("⚠️ ")
2373
+ file_path = next((p for p in self.image_paths if os.path.basename(p) == file_name), None)
2374
+ if not file_path:
2375
+ return
2376
+ idx = self.image_paths.index(file_path)
2377
+ entry = self.loaded_images[idx]
2378
+
2379
+ # Find main window + doc manager
2380
+ mw = self._main_window()
2381
+ dm = self.doc_manager or (getattr(mw, "docman", None) if mw else None)
2382
+ if not mw or not dm:
2383
+ QMessageBox.warning(self, "Document Manager", "Main window or DocManager not available.")
2384
+ return
2385
+
2386
+ # Prepare image + metadata for a real document
2387
+ np_image_f01 = self._as_float01(entry['image_data']) # ensure float32 [0..1]
2388
+ metadata = {
2389
+ 'file_path': file_path,
2390
+ 'original_header': entry.get('header', {}),
2391
+ 'bit_depth': entry.get('bit_depth'),
2392
+ 'is_mono': entry.get('is_mono'),
2393
+ 'source': 'BlinkComparatorPro',
2394
+ }
2395
+ title = os.path.basename(file_path)
2396
+
2397
+ # Create the document using whatever API your DocManager has
2398
+ doc = None
2399
+ try:
2400
+ if hasattr(dm, "open_array"):
2401
+ doc = dm.open_array(np_image_f01, metadata=metadata, title=title)
2402
+ elif hasattr(dm, "open_numpy"):
2403
+ doc = dm.open_numpy(np_image_f01, metadata=metadata, title=title)
2404
+ elif hasattr(dm, "create_document"):
2405
+ doc = dm.create_document(image=np_image_f01, metadata=metadata, name=title)
2406
+ else:
2407
+ raise AttributeError("DocManager lacks open_array/open_numpy/create_document")
2408
+ except Exception as e:
2409
+ QMessageBox.critical(self, "Doc Manager", f"Failed to create document:\n{e}")
2410
+ return
2411
+
2412
+ if doc is None:
2413
+ QMessageBox.critical(self, "Doc Manager", "DocManager returned no document.")
2414
+ return
2415
+
2416
+ # SHOW it: ask the main window to spawn an MDI subwindow
2417
+ try:
2418
+ mw._spawn_subwindow_for(doc)
2419
+ if hasattr(mw, "_log"):
2420
+ mw._log(f"Blink → opened '{title}' as new document")
2421
+ except Exception as e:
2422
+ QMessageBox.critical(self, "UI", f"Failed to open subwindow:\n{e}")
2423
+
2424
+
2425
+ # optional shim to keep any old calls working
2426
+ def push_image_to_manager(self, item):
2427
+ self.push_to_docs(item)
2428
+
2429
+
2430
+
2431
+ def rename_item(self, item):
2432
+ """Allow the user to rename the selected image."""
2433
+ current_name = item.text(0).lstrip("⚠️ ")
2434
+ new_name, ok = QInputDialog.getText(self, "Rename Image", "Enter new name:", text=current_name)
2435
+
2436
+ if ok and new_name:
2437
+ file_path = next((path for path in self.image_paths if os.path.basename(path) == current_name), None)
2438
+ if file_path:
2439
+ # Get the new file path with the new name
2440
+ new_file_path = os.path.join(os.path.dirname(file_path), new_name)
2441
+
2442
+ try:
2443
+ # Rename the file
2444
+ os.rename(file_path, new_file_path)
2445
+ print(f"File renamed from {current_name} to {new_name}")
2446
+
2447
+ # Update the image paths and tree view
2448
+ self.image_paths[self.image_paths.index(file_path)] = new_file_path
2449
+ item.setText(0, new_name)
2450
+ except Exception as e:
2451
+ QMessageBox.critical(self, "Error", f"Failed to rename the file: {e}")
2452
+
2453
+ def rename_flagged_images(self):
2454
+ """Prefix all *flagged* images on disk and in the tree."""
2455
+ # Collect indices of flagged frames
2456
+ flagged_indices = [i for i, e in enumerate(self.loaded_images)
2457
+ if e.get("flagged", False)]
2458
+
2459
+ if not flagged_indices:
2460
+ QMessageBox.information(
2461
+ self,
2462
+ "Rename Flagged Images",
2463
+ "There are no flagged images to rename."
2464
+ )
2465
+ return
2466
+
2467
+ # Small dialog like in your mockup: just a prefix field
2468
+ dlg = QDialog(self)
2469
+ dlg.setWindowTitle("Rename flagged images")
2470
+ layout = QVBoxLayout(dlg)
2471
+
2472
+ layout.addWidget(QLabel("Prefix to add to flagged image filenames:", dlg))
2473
+
2474
+ prefix_edit = QLineEdit(dlg)
2475
+ prefix_edit.setText("Bad_") # sensible default
2476
+ layout.addWidget(prefix_edit)
2477
+
2478
+ btn_box = QDialogButtonBox(
2479
+ QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel,
2480
+ parent=dlg,
2481
+ )
2482
+ btn_box.accepted.connect(dlg.accept)
2483
+ btn_box.rejected.connect(dlg.reject)
2484
+ layout.addWidget(btn_box)
2485
+
2486
+ if dlg.exec() != QDialog.DialogCode.Accepted:
2487
+ return
2488
+
2489
+ prefix = prefix_edit.text()
2490
+ if prefix is None:
2491
+ prefix = ""
2492
+ prefix = prefix.strip()
2493
+ if not prefix:
2494
+ # Allow empty but warn – otherwise user may be confused
2495
+ ret = QMessageBox.question(
2496
+ self,
2497
+ "No Prefix",
2498
+ "No prefix entered. This will not change any filenames.\n\n"
2499
+ "Continue anyway?",
2500
+ QMessageBox.StandardButton.Yes | QDialogButtonBox.StandardButton.No,
2501
+ QMessageBox.StandardButton.No,
2502
+ )
2503
+ if ret != QMessageBox.StandardButton.Yes:
2504
+ return
2505
+
2506
+ successes = 0
2507
+ failures = []
2508
+
2509
+ for idx in flagged_indices:
2510
+ old_path = self.image_paths[idx]
2511
+ directory, base = os.path.split(old_path)
2512
+
2513
+ new_base = f"{prefix}{base}"
2514
+ new_path = os.path.join(directory, new_base)
2515
+
2516
+ # Skip if unchanged
2517
+ if new_path == old_path:
2518
+ continue
2519
+
2520
+ # Avoid overwriting an existing file
2521
+ if os.path.exists(new_path):
2522
+ failures.append((old_path, "target already exists"))
2523
+ continue
2524
+
2525
+ try:
2526
+ os.rename(old_path, new_path)
2527
+ except Exception as e:
2528
+ failures.append((old_path, str(e)))
2529
+ continue
2530
+
2531
+ # Update internal paths
2532
+ self.image_paths[idx] = new_path
2533
+ self.loaded_images[idx]["file_path"] = new_path
2534
+
2535
+ # Update tree item text + UserRole data
2536
+ item = self.get_tree_item_for_index(idx)
2537
+ if item is not None:
2538
+ # preserve ⚠️ prefix
2539
+ disp_name = new_base
2540
+ if self.loaded_images[idx].get("flagged", False):
2541
+ disp_name = f"⚠️ {disp_name}"
2542
+ item.setText(0, disp_name)
2543
+ item.setData(0, Qt.ItemDataRole.UserRole, new_path)
2544
+
2545
+ successes += 1
2546
+
2547
+ # Rebuild tree so new names are naturally re-sorted, keep flags
2548
+ self._after_list_changed()
2549
+ # Also sync the metrics panel flags/colors
2550
+ self._sync_metrics_flags()
2551
+
2552
+ msg = f"Renamed {successes} flagged image{'s' if successes != 1 else ''}."
2553
+ if failures:
2554
+ msg += f"\n\n{len(failures)} file(s) could not be renamed:"
2555
+ for old, err in failures[:10]: # don’t spam too hard
2556
+ msg += f"\n• {os.path.basename(old)} – {err}"
2557
+
2558
+ QMessageBox.information(self, "Rename Flagged Images", msg)
2559
+
2560
+
2561
+ def batch_rename_items(self):
2562
+ """Batch rename selected items by adding a prefix or suffix."""
2563
+ selected_items = self.fileTree.selectedItems()
2564
+
2565
+ if not selected_items:
2566
+ QMessageBox.warning(self, "Warning", "No items selected for renaming.")
2567
+ return
2568
+
2569
+ # Create a custom dialog for entering the prefix and suffix
2570
+ dialog = QDialog(self)
2571
+ dialog.setWindowTitle("Batch Rename")
2572
+ dialog_layout = QVBoxLayout(dialog)
2573
+
2574
+ instruction_label = QLabel("Enter a prefix or suffix to rename selected files:")
2575
+ dialog_layout.addWidget(instruction_label)
2576
+
2577
+ # Create fields for prefix and suffix
2578
+ form_layout = QHBoxLayout()
2579
+
2580
+ prefix_field = QLineEdit(dialog)
2581
+ prefix_field.setPlaceholderText("Prefix")
2582
+ form_layout.addWidget(prefix_field)
2583
+
2584
+ current_filename_label = QLabel("currentfilename", dialog)
2585
+ form_layout.addWidget(current_filename_label)
2586
+
2587
+ suffix_field = QLineEdit(dialog)
2588
+ suffix_field.setPlaceholderText("Suffix")
2589
+ form_layout.addWidget(suffix_field)
2590
+
2591
+ dialog_layout.addLayout(form_layout)
2592
+
2593
+ # Add OK and Cancel buttons
2594
+ button_layout = QHBoxLayout()
2595
+ ok_button = QPushButton("OK", dialog)
2596
+ ok_button.clicked.connect(dialog.accept)
2597
+ button_layout.addWidget(ok_button)
2598
+
2599
+ cancel_button = QPushButton("Cancel", dialog)
2600
+ cancel_button.clicked.connect(dialog.reject)
2601
+ button_layout.addWidget(cancel_button)
2602
+
2603
+ dialog_layout.addLayout(button_layout)
2604
+
2605
+ # Show the dialog and handle user input
2606
+ if dialog.exec() == QDialog.DialogCode.Accepted:
2607
+ prefix = prefix_field.text().strip()
2608
+ suffix = suffix_field.text().strip()
2609
+
2610
+ # Rename each selected file
2611
+ for item in selected_items:
2612
+ current_name = item.text(0)
2613
+ file_path = next((path for path in self.image_paths if os.path.basename(path) == current_name), None)
2614
+
2615
+ if file_path:
2616
+ # Construct the new filename
2617
+ directory = os.path.dirname(file_path)
2618
+ new_name = f"{prefix}{current_name}{suffix}"
2619
+ new_file_path = os.path.join(directory, new_name)
2620
+
2621
+ try:
2622
+ # Rename the file
2623
+ os.rename(file_path, new_file_path)
2624
+ print(f"File renamed from {file_path} to {new_file_path}")
2625
+
2626
+ # Update the paths and tree view
2627
+ self.image_paths[self.image_paths.index(file_path)] = new_file_path
2628
+ item.setText(0, new_name)
2629
+
2630
+ except Exception as e:
2631
+ print(f"Failed to rename {file_path}: {e}")
2632
+ QMessageBox.critical(self, "Error", f"Failed to rename the file: {e}")
2633
+
2634
+ print(f"Batch renamed {len(selected_items)} items.")
2635
+
2636
+ def batch_delete_flagged_images(self):
2637
+ """Delete all flagged images."""
2638
+ flagged_images = [img for img in self.loaded_images if img['flagged']]
2639
+
2640
+ if not flagged_images:
2641
+ QMessageBox.information(self, "No Flagged Images", "There are no flagged images to delete.")
2642
+ return
2643
+
2644
+ confirmation = QMessageBox.question(
2645
+ self,
2646
+ "Confirm Batch Deletion",
2647
+ f"Are you sure you want to permanently delete {len(flagged_images)} flagged images? This action is irreversible.",
2648
+ QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
2649
+ QMessageBox.StandardButton.No
2650
+ )
2651
+
2652
+ if confirmation == QMessageBox.StandardButton.Yes:
2653
+ removed_indices = []
2654
+ # snapshot the indices before mutation
2655
+ for img in flagged_images:
2656
+ try:
2657
+ removed_indices.append(self.image_paths.index(img['file_path']))
2658
+ except ValueError:
2659
+ pass
2660
+
2661
+ # perform deletions
2662
+ for img in flagged_images:
2663
+ file_path = img['file_path']
2664
+ try:
2665
+ os.remove(file_path)
2666
+ except Exception as e:
2667
+ ...
2668
+ # remove from structures
2669
+ if file_path in self.image_paths:
2670
+ self.image_paths.remove(file_path)
2671
+ if img in self.loaded_images:
2672
+ self.loaded_images.remove(img)
2673
+ self.remove_item_from_tree(file_path)
2674
+
2675
+ QMessageBox.information(self, "Batch Deletion", f"Deleted {len(removed_indices)} flagged images.")
2676
+
2677
+ # 🔁 refresh tree + metrics (no recompute)
2678
+ self._after_list_changed(removed_indices)
2679
+
2680
+ def batch_move_flagged_images(self):
2681
+ """Move all flagged images to a selected directory."""
2682
+ flagged_images = [img for img in self.loaded_images if img['flagged']]
2683
+
2684
+ if not flagged_images:
2685
+ QMessageBox.information(self, "No Flagged Images", "There are no flagged images to move.")
2686
+ return
2687
+
2688
+ # Select destination directory
2689
+ destination_dir = QFileDialog.getExistingDirectory(self, "Select Destination Folder", "")
2690
+ if not destination_dir:
2691
+ return # User canceled
2692
+
2693
+ for img in flagged_images:
2694
+ src_path = img['file_path']
2695
+ file_name = os.path.basename(src_path)
2696
+ dest_path = os.path.join(destination_dir, file_name)
2697
+
2698
+ try:
2699
+ os.rename(src_path, dest_path)
2700
+ print(f"Moved flagged image from {src_path} to {dest_path}")
2701
+ except Exception as e:
2702
+ print(f"Failed to move {src_path}: {e}")
2703
+ QMessageBox.critical(self, "Error", f"Failed to move {src_path}: {e}")
2704
+ continue
2705
+
2706
+ # Update data structures
2707
+ self.image_paths.remove(src_path)
2708
+ self.image_paths.append(dest_path)
2709
+ img['file_path'] = dest_path
2710
+ img['flagged'] = False # Reset flag if desired
2711
+
2712
+ # Update tree view
2713
+ self.remove_item_from_tree(src_path)
2714
+ self.add_item_to_tree(dest_path)
2715
+
2716
+ QMessageBox.information(self, "Batch Move", f"Moved {len(flagged_images)} flagged images.")
2717
+ self._after_list_changed(removed_indices=None)
2718
+
2719
+ def move_items(self):
2720
+ """Move selected images *and* remove them from the tree+metrics."""
2721
+ selected_items = self.fileTree.selectedItems()
2722
+ if not selected_items:
2723
+ QMessageBox.warning(self, "Warning", "No items selected for moving.")
2724
+ return
2725
+
2726
+ # Ask where to move
2727
+ new_dir = QFileDialog.getExistingDirectory(self,
2728
+ "Select Destination Folder",
2729
+ "")
2730
+ if not new_dir:
2731
+ return
2732
+
2733
+ # Keep track of which on‐disk paths we actually moved
2734
+ moved_old_paths = []
2735
+ removed_indices = []
2736
+
2737
+ for item in selected_items:
2738
+ name = item.text(0).lstrip("⚠️ ")
2739
+ old_path = next((p for p in self.image_paths
2740
+ if os.path.basename(p) == name), None)
2741
+ if not old_path:
2742
+ continue
2743
+ removed_indices.append(self.image_paths.index(old_path))
2744
+
2745
+ new_path = os.path.join(new_dir, name)
2746
+ try:
2747
+ os.rename(old_path, new_path)
2748
+ except Exception as e:
2749
+ QMessageBox.critical(self, "Error", f"Failed to move {old_path}: {e}")
2750
+ continue
2751
+
2752
+ moved_old_paths.append(old_path)
2753
+
2754
+ # 1) Remove the leaf from the tree
2755
+ parent = item.parent() or self.fileTree.invisibleRootItem()
2756
+ parent.removeChild(item)
2757
+
2758
+ # 2) Purge them from your internal lists
2759
+ for idx in sorted(removed_indices, reverse=True):
2760
+ del self.image_paths[idx]
2761
+ del self.loaded_images[idx]
2762
+
2763
+ self._after_list_changed(removed_indices)
2764
+ print(f"Moved and removed {len(removed_indices)} items.")
2765
+
2766
+
2767
+
2768
+ def delete_items(self):
2769
+ """Delete the selected items from the tree, the loaded images list, and the file system."""
2770
+ selected_items = self.fileTree.selectedItems()
2771
+
2772
+ if not selected_items:
2773
+ QMessageBox.warning(self, "Warning", "No items selected for deletion.")
2774
+ return
2775
+
2776
+ # Confirmation dialog
2777
+ reply = QMessageBox.question(
2778
+ self,
2779
+ 'Confirm Deletion',
2780
+ f"Are you sure you want to permanently delete {len(selected_items)} selected images? This action is irreversible.",
2781
+ QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
2782
+ QMessageBox.StandardButton.No
2783
+ )
2784
+
2785
+ removed_indices = []
2786
+ if reply == QMessageBox.StandardButton.Yes:
2787
+ for item in selected_items:
2788
+ file_name = item.text(0).lstrip("⚠️ ")
2789
+ file_path = next((path for path in self.image_paths if os.path.basename(path) == file_name), None)
2790
+ if file_path:
2791
+ try:
2792
+ idx = self.image_paths.index(file_path)
2793
+ removed_indices.append(idx) # collect BEFORE mutation
2794
+ ...
2795
+ os.remove(file_path)
2796
+ except Exception as e:
2797
+ ...
2798
+ # Remove from widgets
2799
+ for item in selected_items:
2800
+ parent = item.parent() or self.fileTree.invisibleRootItem()
2801
+ parent.removeChild(item)
2802
+
2803
+ # Purge arrays (descending order)
2804
+ for idx in sorted(removed_indices, reverse=True):
2805
+ del self.image_paths[idx]
2806
+ del self.loaded_images[idx]
2807
+
2808
+ # Clear preview
2809
+ self.preview_label.clear()
2810
+ self.preview_label.setText('No image selected.')
2811
+ self.current_image = None
2812
+
2813
+ # 🔁 refresh tree + metrics (no recompute)
2814
+ self._after_list_changed(removed_indices)
2815
+
2816
+ def eventFilter(self, source, event):
2817
+ """Handle mouse events for dragging."""
2818
+ if source == self.scroll_area.viewport():
2819
+ if event.type() == QEvent.Type.MouseButtonPress and event.button() == Qt.MouseButton.LeftButton:
2820
+ # Start dragging
2821
+ self.dragging = True
2822
+ self.last_mouse_pos = event.pos()
2823
+ return True
2824
+ elif event.type() == QEvent.Type.MouseMove and self.dragging:
2825
+ # Handle dragging
2826
+ delta = event.pos() - self.last_mouse_pos
2827
+ self.scroll_area.horizontalScrollBar().setValue(
2828
+ self.scroll_area.horizontalScrollBar().value() - delta.x()
2829
+ )
2830
+ self.scroll_area.verticalScrollBar().setValue(
2831
+ self.scroll_area.verticalScrollBar().value() - delta.y()
2832
+ )
2833
+ self.last_mouse_pos = event.pos()
2834
+ return True
2835
+ elif event.type() == QEvent.Type.MouseButtonRelease and event.button() == Qt.MouseButton.LeftButton:
2836
+ self.dragging = False
2837
+ self._capture_view_center_norm() # remember where the user panned to
2838
+ return True
2839
+ return super().eventFilter(source, event)
2840
+
2841
+ def on_selection_changed(self, selected, deselected):
2842
+ items = self.fileTree.selectedItems()
2843
+ if not items:
2844
+ return
2845
+ item = items[0]
2846
+
2847
+ # if a group got selected, ignore (or auto-drill to first leaf if you prefer)
2848
+ if item.childCount() > 0:
2849
+ return
2850
+
2851
+ name = item.text(0).lstrip("⚠️ ").strip()
2852
+ if self._last_preview_name == name:
2853
+ return # no-op, same item
2854
+
2855
+ # debounce: only preview the last selection after brief idle
2856
+ self._pending_preview_item = item
2857
+ self._pending_preview_timer.start()
2858
+
2859
+ def _do_preview_update(self):
2860
+ item = self._pending_preview_item
2861
+ if not item or item.treeWidget() is None: # ← item got deleted
2862
+ return
2863
+ cur = self.fileTree.currentItem()
2864
+ if cur is not item:
2865
+ return
2866
+ name = item.text(0).lstrip("⚠️ ").strip()
2867
+ self._last_preview_name = name
2868
+ self.on_item_clicked(item, 0)
2869
+
2870
+ def toggle_aggressive(self):
2871
+ self.aggressive_stretch_enabled = self.aggressive_button.isChecked()
2872
+ cur = self.fileTree.currentItem()
2873
+ if cur:
2874
+ self._last_preview_name = None # force reload even if same item
2875
+ self.on_item_clicked(cur, 0)
2876
+
2877
+ def convert_to_qimage(self, img_array):
2878
+ """Convert numpy image array to QImage."""
2879
+ # 1) Bring everything into a uint8 (0–255) array
2880
+ if img_array.dtype == np.uint8:
2881
+ arr8 = img_array
2882
+ elif img_array.dtype == np.uint16:
2883
+ # downscale 16-bit → 8-bit
2884
+ arr8 = (img_array.astype(np.float32) / 65535.0 * 255.0).clip(0,255).astype(np.uint8)
2885
+ else:
2886
+ # assume float in [0..1]
2887
+ arr8 = (img_array.clip(0.0, 1.0) * 255.0).astype(np.uint8)
2888
+
2889
+ h, w = arr8.shape[:2]
2890
+ buffer = arr8.tobytes()
2891
+
2892
+ if arr8.ndim == 3:
2893
+ # RGB
2894
+ return QImage(buffer, w, h, 3*w, QImage.Format.Format_RGB888)
2895
+ else:
2896
+ # grayscale
2897
+ return QImage(buffer, w, h, w, QImage.Format.Format_Grayscale8)
2898
+
2899
+ def _main_window(self):
2900
+ w = self
2901
+ from PyQt6.QtWidgets import QMainWindow, QApplication
2902
+ while w is not None and not isinstance(w, QMainWindow):
2903
+ w = w.parentWidget()
2904
+ if w is not None:
2905
+ return w
2906
+ # fallback: scan toplevels
2907
+ for tlw in QApplication.topLevelWidgets():
2908
+ if isinstance(tlw, QMainWindow):
2909
+ return tlw
2910
+ return None
2911
+
2912
+ # Import centralized widgets
2913
+ from setiastro.saspro.widgets.spinboxes import CustomSpinBox, CustomDoubleSpinBox
2914
+ from setiastro.saspro.widgets.preview_dialogs import ImagePreviewDialog
2915
+
2916
+
2917
+ BlinkComparatorPro = BlinkTab
2918
+
2919
+ # ⬇️ paste your SASv2 code here (exactly as you sent), then end with:
2920
+ class BlinkComparatorPro(BlinkTab):
2921
+ """Alias class so the main app can import a SASpro-named tool."""
2922
+ pass
2923
+