setiastrosuitepro 1.6.2__py3-none-any.whl → 1.6.12__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.
Files changed (162) hide show
  1. setiastro/images/abeicon.svg +16 -0
  2. setiastro/images/acv_icon.png +0 -0
  3. setiastro/images/colorwheel.svg +97 -0
  4. setiastro/images/cosmic.svg +40 -0
  5. setiastro/images/cosmicsat.svg +24 -0
  6. setiastro/images/first_quarter.png +0 -0
  7. setiastro/images/full_moon.png +0 -0
  8. setiastro/images/graxpert.svg +19 -0
  9. setiastro/images/last_quarter.png +0 -0
  10. setiastro/images/linearfit.svg +32 -0
  11. setiastro/images/new_moon.png +0 -0
  12. setiastro/images/pixelmath.svg +42 -0
  13. setiastro/images/rotatearbitrary.png +0 -0
  14. setiastro/images/waning_crescent_1.png +0 -0
  15. setiastro/images/waning_crescent_2.png +0 -0
  16. setiastro/images/waning_crescent_3.png +0 -0
  17. setiastro/images/waning_crescent_4.png +0 -0
  18. setiastro/images/waning_crescent_5.png +0 -0
  19. setiastro/images/waning_gibbous_1.png +0 -0
  20. setiastro/images/waning_gibbous_2.png +0 -0
  21. setiastro/images/waning_gibbous_3.png +0 -0
  22. setiastro/images/waning_gibbous_4.png +0 -0
  23. setiastro/images/waning_gibbous_5.png +0 -0
  24. setiastro/images/waxing_crescent_1.png +0 -0
  25. setiastro/images/waxing_crescent_2.png +0 -0
  26. setiastro/images/waxing_crescent_3.png +0 -0
  27. setiastro/images/waxing_crescent_4.png +0 -0
  28. setiastro/images/waxing_crescent_5.png +0 -0
  29. setiastro/images/waxing_gibbous_1.png +0 -0
  30. setiastro/images/waxing_gibbous_2.png +0 -0
  31. setiastro/images/waxing_gibbous_3.png +0 -0
  32. setiastro/images/waxing_gibbous_4.png +0 -0
  33. setiastro/images/waxing_gibbous_5.png +0 -0
  34. setiastro/qml/ResourceMonitor.qml +84 -82
  35. setiastro/saspro/__main__.py +20 -1
  36. setiastro/saspro/_generated/build_info.py +2 -2
  37. setiastro/saspro/abe.py +37 -4
  38. setiastro/saspro/aberration_ai.py +237 -21
  39. setiastro/saspro/acv_exporter.py +379 -0
  40. setiastro/saspro/add_stars.py +33 -6
  41. setiastro/saspro/backgroundneutral.py +114 -37
  42. setiastro/saspro/blemish_blaster.py +4 -1
  43. setiastro/saspro/blink_comparator_pro.py +548 -275
  44. setiastro/saspro/clahe.py +4 -1
  45. setiastro/saspro/continuum_subtract.py +4 -1
  46. setiastro/saspro/convo.py +13 -7
  47. setiastro/saspro/cosmicclarity.py +129 -18
  48. setiastro/saspro/crop_dialog_pro.py +134 -8
  49. setiastro/saspro/curve_editor_pro.py +109 -42
  50. setiastro/saspro/doc_manager.py +246 -16
  51. setiastro/saspro/exoplanet_detector.py +120 -28
  52. setiastro/saspro/frequency_separation.py +1158 -204
  53. setiastro/saspro/function_bundle.py +16 -16
  54. setiastro/saspro/ghs_dialog_pro.py +81 -16
  55. setiastro/saspro/graxpert.py +1 -0
  56. setiastro/saspro/gui/main_window.py +519 -289
  57. setiastro/saspro/gui/mixins/dock_mixin.py +276 -42
  58. setiastro/saspro/gui/mixins/geometry_mixin.py +105 -5
  59. setiastro/saspro/gui/mixins/menu_mixin.py +28 -1
  60. setiastro/saspro/gui/mixins/theme_mixin.py +160 -14
  61. setiastro/saspro/gui/mixins/toolbar_mixin.py +416 -27
  62. setiastro/saspro/gui/mixins/update_mixin.py +138 -36
  63. setiastro/saspro/gui/mixins/view_mixin.py +42 -0
  64. setiastro/saspro/halobgon.py +4 -0
  65. setiastro/saspro/histogram.py +5 -1
  66. setiastro/saspro/image_combine.py +4 -0
  67. setiastro/saspro/image_peeker_pro.py +4 -0
  68. setiastro/saspro/imageops/starbasedwhitebalance.py +23 -52
  69. setiastro/saspro/imageops/stretch.py +582 -62
  70. setiastro/saspro/isophote.py +4 -0
  71. setiastro/saspro/layers.py +13 -9
  72. setiastro/saspro/layers_dock.py +183 -3
  73. setiastro/saspro/legacy/image_manager.py +154 -20
  74. setiastro/saspro/legacy/numba_utils.py +67 -47
  75. setiastro/saspro/legacy/xisf.py +240 -98
  76. setiastro/saspro/live_stacking.py +180 -79
  77. setiastro/saspro/luminancerecombine.py +228 -27
  78. setiastro/saspro/mask_creation.py +174 -15
  79. setiastro/saspro/mfdeconv.py +113 -35
  80. setiastro/saspro/mfdeconvcudnn.py +119 -70
  81. setiastro/saspro/mfdeconvsport.py +112 -35
  82. setiastro/saspro/morphology.py +4 -0
  83. setiastro/saspro/multiscale_decomp.py +748 -255
  84. setiastro/saspro/numba_utils.py +72 -57
  85. setiastro/saspro/ops/commands.py +18 -18
  86. setiastro/saspro/ops/script_editor.py +10 -2
  87. setiastro/saspro/ops/scripts.py +122 -0
  88. setiastro/saspro/perfect_palette_picker.py +37 -3
  89. setiastro/saspro/plate_solver.py +84 -49
  90. setiastro/saspro/psf_viewer.py +119 -37
  91. setiastro/saspro/remove_stars_preset.py +55 -13
  92. setiastro/saspro/resources.py +97 -11
  93. setiastro/saspro/rgbalign.py +4 -0
  94. setiastro/saspro/selective_color.py +83 -21
  95. setiastro/saspro/sfcc.py +364 -152
  96. setiastro/saspro/shortcuts.py +253 -49
  97. setiastro/saspro/signature_insert.py +692 -33
  98. setiastro/saspro/stacking_suite.py +1610 -574
  99. setiastro/saspro/star_alignment.py +522 -453
  100. setiastro/saspro/star_spikes.py +4 -0
  101. setiastro/saspro/star_stretch.py +38 -3
  102. setiastro/saspro/stat_stretch.py +743 -128
  103. setiastro/saspro/status_log_dock.py +1 -1
  104. setiastro/saspro/subwindow.py +786 -360
  105. setiastro/saspro/supernovaasteroidhunter.py +1 -1
  106. setiastro/saspro/swap_manager.py +77 -42
  107. setiastro/saspro/translations/all_source_strings.json +1588 -516
  108. setiastro/saspro/translations/ar_translations.py +915 -684
  109. setiastro/saspro/translations/de_translations.py +442 -463
  110. setiastro/saspro/translations/es_translations.py +277 -47
  111. setiastro/saspro/translations/fr_translations.py +279 -47
  112. setiastro/saspro/translations/hi_translations.py +253 -21
  113. setiastro/saspro/translations/integrate_translations.py +3 -2
  114. setiastro/saspro/translations/it_translations.py +1211 -161
  115. setiastro/saspro/translations/ja_translations.py +3340 -3107
  116. setiastro/saspro/translations/pt_translations.py +3315 -3337
  117. setiastro/saspro/translations/ru_translations.py +351 -117
  118. setiastro/saspro/translations/saspro_ar.qm +0 -0
  119. setiastro/saspro/translations/saspro_ar.ts +15902 -138
  120. setiastro/saspro/translations/saspro_de.qm +0 -0
  121. setiastro/saspro/translations/saspro_de.ts +14428 -133
  122. setiastro/saspro/translations/saspro_es.qm +0 -0
  123. setiastro/saspro/translations/saspro_es.ts +11503 -7821
  124. setiastro/saspro/translations/saspro_fr.qm +0 -0
  125. setiastro/saspro/translations/saspro_fr.ts +11168 -7812
  126. setiastro/saspro/translations/saspro_hi.qm +0 -0
  127. setiastro/saspro/translations/saspro_hi.ts +14733 -135
  128. setiastro/saspro/translations/saspro_it.qm +0 -0
  129. setiastro/saspro/translations/saspro_it.ts +14347 -7821
  130. setiastro/saspro/translations/saspro_ja.qm +0 -0
  131. setiastro/saspro/translations/saspro_ja.ts +14860 -137
  132. setiastro/saspro/translations/saspro_pt.qm +0 -0
  133. setiastro/saspro/translations/saspro_pt.ts +14904 -137
  134. setiastro/saspro/translations/saspro_ru.qm +0 -0
  135. setiastro/saspro/translations/saspro_ru.ts +11766 -168
  136. setiastro/saspro/translations/saspro_sw.qm +0 -0
  137. setiastro/saspro/translations/saspro_sw.ts +15115 -135
  138. setiastro/saspro/translations/saspro_uk.qm +0 -0
  139. setiastro/saspro/translations/saspro_uk.ts +11206 -6729
  140. setiastro/saspro/translations/saspro_zh.qm +0 -0
  141. setiastro/saspro/translations/saspro_zh.ts +10581 -7812
  142. setiastro/saspro/translations/sw_translations.py +282 -56
  143. setiastro/saspro/translations/uk_translations.py +264 -35
  144. setiastro/saspro/translations/zh_translations.py +282 -47
  145. setiastro/saspro/view_bundle.py +17 -17
  146. setiastro/saspro/wavescale_hdr.py +4 -1
  147. setiastro/saspro/wavescalede.py +4 -1
  148. setiastro/saspro/whitebalance.py +84 -12
  149. setiastro/saspro/widgets/common_utilities.py +28 -21
  150. setiastro/saspro/widgets/minigame/game.js +11 -6
  151. setiastro/saspro/widgets/resource_monitor.py +133 -57
  152. setiastro/saspro/widgets/spinboxes.py +28 -13
  153. setiastro/saspro/wimi.py +92 -721
  154. setiastro/saspro/wims.py +46 -36
  155. setiastro/saspro/window_shelf.py +2 -2
  156. setiastro/saspro/xisf.py +101 -11
  157. {setiastrosuitepro-1.6.2.dist-info → setiastrosuitepro-1.6.12.dist-info}/METADATA +8 -7
  158. {setiastrosuitepro-1.6.2.dist-info → setiastrosuitepro-1.6.12.dist-info}/RECORD +162 -128
  159. {setiastrosuitepro-1.6.2.dist-info → setiastrosuitepro-1.6.12.dist-info}/WHEEL +0 -0
  160. {setiastrosuitepro-1.6.2.dist-info → setiastrosuitepro-1.6.12.dist-info}/entry_points.txt +0 -0
  161. {setiastrosuitepro-1.6.2.dist-info → setiastrosuitepro-1.6.12.dist-info}/licenses/LICENSE +0 -0
  162. {setiastrosuitepro-1.6.2.dist-info → setiastrosuitepro-1.6.12.dist-info}/licenses/license.txt +0 -0
@@ -150,45 +150,75 @@ class MetricsPanel(QWidget):
150
150
  orig_back = entry.get('orig_background', np.nan)
151
151
  return idx, fwhm, ecc, orig_back, star_cnt
152
152
 
153
-
154
- def compute_all_metrics(self, loaded_images):
155
- """Run SEP over the full list in parallel using threads and cache results."""
153
+ def compute_all_metrics(self, loaded_images) -> bool:
154
+ """Run SEP over the full list in parallel using threads and cache results.
155
+ Returns True if metrics were computed, False if user declined/canceled.
156
+ """
156
157
  n = len(loaded_images)
157
158
  if n == 0:
158
- # Clear any previous state and bail
159
159
  self._orig_images = []
160
- self.metrics_data = [np.array([])]*4
160
+ self.metrics_data = [np.array([])] * 4
161
161
  self.flags = []
162
- self._threshold_initialized = [False]*4
163
- return
162
+ self._threshold_initialized = [False] * 4
163
+ return True
164
+
165
+ def _has_metrics(md):
166
+ try:
167
+ return md is not None and len(md) == 4 and md[0] is not None and len(md[0]) > 0
168
+ except Exception:
169
+ return False
164
170
 
165
- # Heads-up dialog (as you already had)
166
171
  settings = QSettings()
167
- show = settings.value("metrics/showWarning", True, type=bool)
168
- if show:
172
+ show_warning = settings.value("metrics/showWarning", True, type=bool)
173
+
174
+ if (not show_warning) and (not _has_metrics(getattr(self, "metrics_data", None))):
175
+ settings.setValue("metrics/showWarning", True)
176
+ show_warning = True
177
+
178
+ # ----------------------------
179
+ # 1) Optional warning gate
180
+ # ----------------------------
181
+ if show_warning:
169
182
  msg = QMessageBox(self)
170
183
  msg.setWindowTitle(self.tr("Heads-up"))
171
184
  msg.setText(self.tr(
172
185
  "This is going to use ALL your CPU cores and the UI may lock up until it finishes.\n\n"
173
186
  "Continue?"
174
187
  ))
175
- msg.setStandardButtons(QMessageBox.StandardButton.Yes |
176
- QMessageBox.StandardButton.No)
188
+ msg.setStandardButtons(
189
+ QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
190
+ )
177
191
  cb = QCheckBox(self.tr("Don't show again"), msg)
178
192
  msg.setCheckBox(cb)
179
- if msg.exec() != QMessageBox.StandardButton.Yes:
180
- return
193
+
194
+ clicked = msg.exec()
195
+ clicked_yes = (clicked == QMessageBox.StandardButton.Yes)
196
+
197
+ if not clicked_yes:
198
+ # If they said NO, never allow "Don't show again" to lock them out.
199
+ # Keep the warning enabled so they can opt-in later.
200
+ if cb.isChecked():
201
+ settings.setValue("metrics/showWarning", True)
202
+ return False
203
+
204
+ # They said YES: now it's safe to honor "Don't show again"
181
205
  if cb.isChecked():
182
206
  settings.setValue("metrics/showWarning", False)
183
207
 
184
- # pre-allocate result arrays
208
+ # If show_warning is False, we compute with no prompt.
209
+
210
+ # ----------------------------
211
+ # 2) Allocate result arrays
212
+ # ----------------------------
185
213
  m0 = np.full(n, np.nan, dtype=np.float32) # FWHM
186
214
  m1 = np.full(n, np.nan, dtype=np.float32) # Eccentricity
187
215
  m2 = np.full(n, np.nan, dtype=np.float32) # Background (cached)
188
216
  m3 = np.full(n, np.nan, dtype=np.float32) # Star count
189
217
  flags = [e.get('flagged', False) for e in loaded_images]
190
218
 
191
- # progress dialog
219
+ # ----------------------------
220
+ # 3) Progress dialog
221
+ # ----------------------------
192
222
  prog = QProgressDialog(self.tr("Computing frame metrics…"), self.tr("Cancel"), 0, n, self)
193
223
  prog.setWindowModality(Qt.WindowModality.WindowModal)
194
224
  prog.setMinimumDuration(0)
@@ -198,32 +228,43 @@ class MetricsPanel(QWidget):
198
228
 
199
229
  workers = min(os.cpu_count() or 1, 60)
200
230
  tasks = [(i, loaded_images[i]) for i in range(n)]
201
- done = 0 # <-- FIX: initialize before incrementing
231
+ done = 0
232
+ canceled = False
202
233
 
203
234
  try:
204
235
  with ThreadPoolExecutor(max_workers=workers) as exe:
205
236
  futures = {exe.submit(self._compute_one, t): t[0] for t in tasks}
206
237
  for fut in as_completed(futures):
207
238
  if prog.wasCanceled():
239
+ canceled = True
208
240
  break
209
241
  try:
210
242
  idx, fwhm, ecc, orig_back, star_cnt = fut.result()
211
243
  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)
244
+ idx = futures.get(fut, 0)
245
+ fwhm, ecc, orig_back, star_cnt = np.nan, np.nan, np.nan, 0
246
+
247
+ if 0 <= idx < n:
248
+ m0[idx], m1[idx], m2[idx], m3[idx] = fwhm, ecc, orig_back, float(star_cnt)
249
+
215
250
  done += 1
216
251
  prog.setValue(done)
217
252
  QApplication.processEvents()
218
253
  finally:
219
254
  prog.close()
220
255
 
221
- # stash results
256
+ if canceled:
257
+ # IMPORTANT: leave caches alone; caller will clear/return
258
+ return False
259
+
260
+ # ----------------------------
261
+ # 4) Stash results
262
+ # ----------------------------
222
263
  self._orig_images = loaded_images
223
264
  self.metrics_data = [m0, m1, m2, m3]
224
265
  self.flags = flags
225
- self._threshold_initialized = [False]*4
226
-
266
+ self._threshold_initialized = [False] * 4
267
+ return True
227
268
 
228
269
  def plot(self, loaded_images, indices=None):
229
270
  """
@@ -242,7 +283,16 @@ class MetricsPanel(QWidget):
242
283
 
243
284
  # compute & cache on first call or new image list
244
285
  if self._orig_images is not loaded_images or self.metrics_data is None:
245
- self.compute_all_metrics(loaded_images)
286
+ ok = self.compute_all_metrics(loaded_images)
287
+ if not ok or self.metrics_data is None:
288
+ # user declined/canceled -> clear plots and exit cleanly
289
+ for pw, scat, line in zip(self.plots, self.scats, self.lines):
290
+ scat.setData(x=[], y=[])
291
+ line.setPos(0)
292
+ pw.getPlotItem().getViewBox().update()
293
+ pw.repaint()
294
+ return
295
+
246
296
 
247
297
  # default to all indices
248
298
  if indices is None:
@@ -479,27 +529,77 @@ class MetricsWindow(QWidget):
479
529
  """
480
530
  Called when some frames were deleted/moved out of the list.
481
531
  Does NOT recompute metrics. Just trims cached arrays and re-plots.
532
+
533
+ Robust against:
534
+ - removed indices referring to the old list (out of range)
535
+ - metrics_panel arrays being a different length than _all_images
536
+ - stale _order_all / _current_indices containing out-of-bounds indices
482
537
  """
483
538
  if not removed:
484
539
  return
485
- removed = sorted(set(int(i) for i in removed))
486
540
 
487
- # 1) shrink cached arrays in the panel
488
- self.metrics_panel.remove_frames(removed)
541
+ # Unique + int
542
+ removed = sorted({int(i) for i in removed})
543
+
544
+ # ---- 1) Trim metrics panel caches SAFELY ----
545
+ # Prefer panel's current frame count, because it represents the arrays we must slice.
546
+ n_panel = getattr(self.metrics_panel, "n_frames", None)
547
+ if callable(n_panel):
548
+ n_panel = n_panel()
549
+ if not isinstance(n_panel, int) or n_panel <= 0:
550
+ # fallback: infer from metrics_data if present
551
+ md = getattr(self.metrics_panel, "metrics_data", None)
552
+ if md is not None and len(md) and md[0] is not None:
553
+ try:
554
+ n_panel = int(len(md[0]))
555
+ except Exception:
556
+ n_panel = 0
557
+ else:
558
+ n_panel = 0
559
+
560
+ if n_panel > 0:
561
+ removed_panel = [i for i in removed if 0 <= i < n_panel]
562
+ if removed_panel:
563
+ self.metrics_panel.remove_frames(removed_panel)
564
+ # else: panel has nothing (or isn't initialized) — just continue with ordering cleanup
489
565
 
490
- # 2) update our “master” list and ordering (object identity unchanged)
491
- # (BlinkTab will already have mutated the underlying list for us)
566
+ # ---- 2) Update ordering arrays with the SAME removed set (but clamp later) ----
492
567
  self._order_all = self._reindex_list_after_remove(self._order_all, removed)
493
- self._current_indices = self._reindex_list_after_remove(self._current_indices, removed)
568
+ if self._current_indices is not None:
569
+ self._current_indices = self._reindex_list_after_remove(self._current_indices, removed)
494
570
 
495
- # 3) rebuild group list (filters may have disappeared)
571
+ # ---- 3) Rebuild groups (filters may have disappeared) ----
496
572
  self._rebuild_groups_from_images()
497
573
 
498
- # 4) replot current group with updated order
574
+ # ---- 4) Plot with VALID indices only ----
575
+ n_imgs = len(self._all_images) if self._all_images is not None else 0
576
+
577
+ def _sanitize_indices(ixs):
578
+ if not ixs:
579
+ return []
580
+ out = []
581
+ seen = set()
582
+ for i in ixs:
583
+ try:
584
+ ii = int(i)
585
+ except Exception:
586
+ continue
587
+ if 0 <= ii < n_imgs and ii not in seen:
588
+ seen.add(ii)
589
+ out.append(ii)
590
+ return out
591
+
499
592
  indices = self._current_indices if self._current_indices is not None else self._order_all
593
+ indices = _sanitize_indices(indices)
594
+
595
+ # If the current group became empty, fall back to "all"
596
+ if not indices and n_imgs:
597
+ indices = list(range(n_imgs))
598
+ self._current_indices = indices # optional: keeps UI consistent
599
+
500
600
  self.metrics_panel.plot(self._all_images, indices=indices)
501
601
 
502
- # 5) recolor & status
602
+ # ---- 5) Recolor & status ----
503
603
  self.metrics_panel.refresh_colors_and_status()
504
604
  self._update_status()
505
605
 
@@ -1111,12 +1211,13 @@ class BlinkTab(QWidget):
1111
1211
 
1112
1212
  if not use_aggr:
1113
1213
  if stored.dtype == np.uint8:
1114
- disp8 = stored
1214
+ return stored
1115
1215
  elif stored.dtype == np.uint16:
1116
- disp8 = (stored >> 8).astype(np.uint8)
1216
+ return (stored >> 8).astype(np.uint8)
1117
1217
  else:
1118
- disp8 = (np.clip(stored, 0.0, 1.0) * 255.0).astype(np.uint8)
1119
- return disp8
1218
+ # display-only normalization for float / weird ranges
1219
+ f01 = self._ensure_float01(stored)
1220
+ return (f01 * 255.0).astype(np.uint8)
1120
1221
 
1121
1222
  base01 = self._as_float01(stored)
1122
1223
 
@@ -1210,11 +1311,29 @@ class BlinkTab(QWidget):
1210
1311
  self.loading_label.setText(self.tr("Loaded {0} image{1}.").format(n, 's' if n != 1 else ''))
1211
1312
 
1212
1313
  def _apply_playback_interval(self, *_):
1213
- # read from custom spin if present
1214
- fps = float(self.speed_spin.value) if hasattr(self, "speed_spin") else float(getattr(self, "play_fps", 1.0))
1314
+ # read from custom spin if present (support both .value() and .value attribute)
1315
+ fps = float(getattr(self, "play_fps", 1.0))
1316
+
1317
+ if hasattr(self, "speed_spin") and self.speed_spin is not None:
1318
+ try:
1319
+ v = getattr(self.speed_spin, "value", None)
1320
+ if callable(v):
1321
+ fps = float(v()) # QDoubleSpinBox-style
1322
+ elif v is not None:
1323
+ fps = float(v) # CustomDoubleSpinBox stores numeric attribute
1324
+ else:
1325
+ # last-resort: try Qt API name
1326
+ fps = float(self.speed_spin.value())
1327
+ except Exception:
1328
+ # fall back to existing play_fps
1329
+ pass
1330
+
1215
1331
  fps = max(0.1, min(10.0, fps))
1216
1332
  self.play_fps = fps
1217
- self.playback_timer.setInterval(int(round(1000.0 / fps))) # 0.1 fps -> 10000 ms
1333
+
1334
+ if hasattr(self, "playback_timer") and self.playback_timer is not None:
1335
+ self.playback_timer.setInterval(int(round(1000.0 / fps))) # 0.1 fps -> 10000 ms
1336
+
1218
1337
 
1219
1338
  def _on_current_item_changed_safe(self, current, previous):
1220
1339
  if not current:
@@ -1239,12 +1358,41 @@ class BlinkTab(QWidget):
1239
1358
  if item is not None:
1240
1359
  self.fileTree.scrollToItem(item, QAbstractItemView.ScrollHint.EnsureVisible)
1241
1360
 
1242
- def toggle_aggressive(self):
1243
- self.aggressive_stretch_enabled = self.aggressive_button.isChecked()
1244
- # force a redisplay of the current image
1245
- cur = self.fileTree.currentItem()
1246
- if cur:
1247
- self.on_item_clicked(cur, 0)
1361
+ def _leaf_path(self, item: QTreeWidgetItem) -> str | None:
1362
+ """Return full path for a leaf item, preferring UserRole; fallback to basename match."""
1363
+ if not item or item.childCount() > 0:
1364
+ return None
1365
+
1366
+ p = item.data(0, Qt.ItemDataRole.UserRole)
1367
+ if p and isinstance(p, str):
1368
+ return p
1369
+
1370
+ # fallback: basename match (legacy items)
1371
+ name = item.text(0).lstrip("⚠️ ").strip()
1372
+ if not name:
1373
+ return None
1374
+ return next((x for x in self.image_paths if os.path.basename(x) == name), None)
1375
+
1376
+
1377
+ def _leaf_index(self, item: QTreeWidgetItem) -> int | None:
1378
+ """Return index into image_paths/loaded_images for a leaf item."""
1379
+ p = self._leaf_path(item)
1380
+ if not p:
1381
+ return None
1382
+ try:
1383
+ return self.image_paths.index(p)
1384
+ except ValueError:
1385
+ return None
1386
+
1387
+
1388
+ def _set_leaf_display(self, item: QTreeWidgetItem, *, base_name: str, flagged: bool, full_path: str):
1389
+ """Update a leaf item's text + UserRole consistently."""
1390
+ disp = base_name
1391
+ if flagged:
1392
+ disp = f"⚠️ {disp}"
1393
+ item.setText(0, disp)
1394
+ item.setData(0, Qt.ItemDataRole.UserRole, full_path)
1395
+
1248
1396
 
1249
1397
  def clearFlags(self):
1250
1398
  """Clear all flagged states, update tree icons & metrics."""
@@ -1507,31 +1655,23 @@ class BlinkTab(QWidget):
1507
1655
 
1508
1656
 
1509
1657
  def _after_list_changed(self, removed_indices: List[int] | None = None):
1510
- """Call after you mutate image_paths/loaded_images. Keeps UI + metrics in sync w/o recompute."""
1511
- # 1) rebuild the tree (groups collapse if empty)
1512
1658
  self._rebuild_tree_from_loaded()
1513
1659
  self.imagesChanged.emit(len(self.loaded_images))
1514
1660
 
1515
- # 2) refresh metrics (if open) WITHOUT recomputing SEP
1516
1661
  if self.metrics_window and self.metrics_window.isVisible():
1517
- if removed_indices:
1518
- # drop points and reindex
1519
- self.metrics_window._all_images = self.loaded_images
1520
- self.metrics_window.remove_indices(list(removed_indices))
1521
- else:
1522
- # just order changed or paths changed -> replot current group
1523
- self.metrics_window.update_metrics(
1524
- self.loaded_images,
1525
- order=self._tree_order_indices()
1526
- )
1662
+ # ✅ safest: rebind images + rebuild plot order from tree
1663
+ self.metrics_window.set_images(self.loaded_images, order=self._tree_order_indices())
1664
+ self._sync_metrics_flags()
1527
1665
 
1528
1666
  def get_tree_item_for_index(self, idx):
1529
- target = os.path.basename(self.image_paths[idx])
1667
+ target_path = self.image_paths[idx]
1530
1668
  for item in self.get_all_leaf_items():
1531
- if item.text(0).lstrip("⚠️ ") == target:
1669
+ p = item.data(0, Qt.ItemDataRole.UserRole)
1670
+ if p == target_path:
1532
1671
  return item
1533
1672
  return None
1534
1673
 
1674
+
1535
1675
  def compute_metric(self, metric_idx, entry):
1536
1676
  """Recompute a single metric for one image. Use cached orig_background for metric 2."""
1537
1677
  # metric 2 is the pre-stretch background we already computed
@@ -1851,26 +1991,28 @@ class BlinkTab(QWidget):
1851
1991
 
1852
1992
 
1853
1993
  def _toggle_flag_on_item(self, item: QTreeWidgetItem, *, sync_metrics: bool = True):
1854
- file_name = item.text(0).lstrip("⚠️ ")
1855
- file_path = next((p for p in self.image_paths if os.path.basename(p) == file_name), None)
1856
- if file_path is None:
1994
+ idx = self._leaf_index(item)
1995
+ if idx is None:
1857
1996
  return
1858
1997
 
1859
- idx = self.image_paths.index(file_path)
1860
1998
  entry = self.loaded_images[idx]
1861
- entry['flagged'] = not entry['flagged']
1999
+ entry['flagged'] = not bool(entry.get('flagged', False))
1862
2000
 
1863
2001
  RED = Qt.GlobalColor.red
1864
- palette = self.fileTree.palette()
1865
- normal_color = palette.color(QPalette.ColorRole.WindowText)
2002
+ normal_color = self.fileTree.palette().color(QPalette.ColorRole.WindowText)
2003
+
2004
+ base = os.path.basename(self.image_paths[idx])
1866
2005
 
1867
2006
  if entry['flagged']:
1868
- item.setText(0, f"⚠️ {file_name}")
2007
+ item.setText(0, f"⚠️ {base}")
1869
2008
  item.setForeground(0, QBrush(RED))
1870
2009
  else:
1871
- item.setText(0, file_name)
2010
+ item.setText(0, base)
1872
2011
  item.setForeground(0, QBrush(normal_color))
1873
2012
 
2013
+ # Keep UserRole correct (in case this was a legacy leaf)
2014
+ item.setData(0, Qt.ItemDataRole.UserRole, self.image_paths[idx])
2015
+
1874
2016
  if sync_metrics:
1875
2017
  self._sync_metrics_flags()
1876
2018
 
@@ -2159,53 +2301,30 @@ class BlinkTab(QWidget):
2159
2301
 
2160
2302
  def on_item_clicked(self, item, column):
2161
2303
  self.fileTree.setFocus()
2304
+ if not item or item.childCount() > 0:
2305
+ return
2162
2306
 
2163
- name = item.text(0).lstrip("⚠️ ").strip()
2164
- file_path = next((p for p in self.image_paths if os.path.basename(p) == name), None)
2307
+ file_path = self._leaf_path(item)
2165
2308
  if not file_path:
2166
2309
  return
2167
2310
 
2168
2311
  self._capture_view_center_norm()
2169
2312
 
2170
- idx = self.image_paths.index(file_path)
2171
- entry = self.loaded_images[idx]
2172
- stored = entry['image_data'] # already stretched & clipped at load time
2173
-
2174
- # --- Fast path: just display what we cached in RAM ---
2175
- if not self.aggressive_stretch_enabled:
2176
- # Convert to 8-bit only if needed (no additional stretch)
2177
- if stored.dtype == np.uint8:
2178
- disp8 = stored
2179
- elif stored.dtype == np.uint16:
2180
- disp8 = (stored >> 8).astype(np.uint8) # ~ /257, quick & vectorized
2181
- else: # float32 in [0..1]
2182
- disp8 = (np.clip(stored, 0.0, 1.0) * 255.0).astype(np.uint8)
2183
-
2184
- else:
2185
- # Aggressive mode: compute only here (from float01)
2186
- base01 = self._as_float01(stored)
2187
- # Siril-style autostretch
2188
- if base01.ndim == 2:
2189
- st = siril_style_autostretch(base01, sigma=self.current_sigma)
2190
- disp01 = self._as_float01(st) # <-- IMPORTANT: handles 0..255 or 0..1 correctly
2191
- else:
2192
- base01 = self._as_float01(stored)
2193
-
2194
- if base01.ndim == 2:
2195
- disp01 = self._aggressive_display_boost(base01, strength=self.current_sigma)
2196
- else:
2197
- lum = base01.mean(axis=2).astype(np.float32)
2198
- lum_boost = self._aggressive_display_boost(lum, strength=self.current_sigma)
2199
- gain = lum_boost / (lum + 1e-6)
2200
- disp01 = np.clip(base01 * gain[..., None], 0.0, 1.0)
2313
+ try:
2314
+ idx = self.image_paths.index(file_path)
2315
+ except ValueError:
2316
+ return
2201
2317
 
2202
- disp8 = (disp01 * 255.0).astype(np.uint8)
2318
+ entry = self.loaded_images[idx]
2203
2319
 
2320
+ # ✅ single source of truth (handles aggressive + mono + color)
2321
+ disp8 = self._make_display_frame(entry)
2204
2322
 
2205
2323
  qimage = self.convert_to_qimage(disp8)
2206
2324
  self.current_pixmap = QPixmap.fromImage(qimage)
2207
2325
  self.apply_zoom()
2208
2326
 
2327
+
2209
2328
  def _capture_view_center_norm(self):
2210
2329
  """Remember the current viewport center as a fraction of the content size."""
2211
2330
  sa = self.scroll_area
@@ -2370,44 +2489,94 @@ class BlinkTab(QWidget):
2370
2489
  menu.exec(self.fileTree.mapToGlobal(pos))
2371
2490
 
2372
2491
 
2373
- def push_to_docs(self, item):
2374
- # Resolve file + entry
2375
- file_name = item.text(0).lstrip("⚠️ ")
2376
- file_path = next((p for p in self.image_paths if os.path.basename(p) == file_name), None)
2492
+ def push_to_docs(self, item: QTreeWidgetItem):
2493
+ """
2494
+ Push the currently selected blink leaf image into DocManager as a new document,
2495
+ preserving all original metadata (original_header, meta, bit_depth, is_mono, etc.)
2496
+ and swapping ONLY the numpy image array.
2497
+ """
2498
+ if not item or item.childCount() > 0:
2499
+ return
2500
+
2501
+ # --- Resolve full path safely (UserRole-first) ---
2502
+ file_path = item.data(0, Qt.ItemDataRole.UserRole)
2503
+ if not file_path or not isinstance(file_path, str):
2504
+ # legacy fallback: try to map by displayed name
2505
+ file_name = item.text(0).lstrip("⚠️ ").strip()
2506
+ file_path = next((p for p in self.image_paths if os.path.basename(p) == file_name), None)
2507
+
2377
2508
  if not file_path:
2378
2509
  return
2379
- idx = self.image_paths.index(file_path)
2510
+
2511
+ try:
2512
+ idx = self.image_paths.index(file_path)
2513
+ except ValueError:
2514
+ return
2515
+
2380
2516
  entry = self.loaded_images[idx]
2381
2517
 
2382
- # Find main window + doc manager
2518
+ # --- Find main window + doc manager ---
2383
2519
  mw = self._main_window()
2384
2520
  dm = self.doc_manager or (getattr(mw, "docman", None) if mw else None)
2385
2521
  if not mw or not dm:
2386
2522
  QMessageBox.warning(self, self.tr("Document Manager"), self.tr("Main window or DocManager not available."))
2387
2523
  return
2388
2524
 
2389
- # Prepare image + metadata for a real document
2390
- np_image_f01 = self._as_float01(entry['image_data']) # ensure float32 [0..1]
2391
- metadata = {
2392
- 'file_path': file_path,
2393
- 'original_header': entry.get('header', {}),
2394
- 'bit_depth': entry.get('bit_depth'),
2395
- 'is_mono': entry.get('is_mono'),
2396
- 'source': 'BlinkComparatorPro',
2525
+ # --- Build the swapped payload (image replaced, metadata preserved) ---
2526
+ # Whatever you're storing as entry['image_data'] (uint16/float/etc), normalize to float01 for display pipeline.
2527
+ # If your DocManager expects native dtype instead, swap _as_float01 for your native image.
2528
+ np_image_f01 = self._as_float01(entry["image_data"]).astype(np.float32, copy=False)
2529
+
2530
+ # Preserve your full load_image return structure as much as possible:
2531
+ # load_image returns: image, original_header, bit_depth, is_mono, meta
2532
+ original_header = entry.get("original_header", entry.get("header", None))
2533
+ bit_depth = entry.get("bit_depth", None)
2534
+ is_mono = entry.get("is_mono", None)
2535
+ meta = entry.get("meta", {})
2536
+
2537
+ # Keep meta dict style your app uses; add source tag without clobbering
2538
+ if isinstance(meta, dict):
2539
+ meta = dict(meta)
2540
+ meta.setdefault("source", "BlinkComparatorPro")
2541
+ meta.setdefault("file_path", file_path)
2542
+
2543
+ # This is the "all the other stuff" you wanted preserved
2544
+ payload = {
2545
+ "file_path": file_path,
2546
+ "original_header": original_header,
2547
+ "bit_depth": bit_depth,
2548
+ "is_mono": is_mono,
2549
+ "meta": meta,
2550
+ "source": "BlinkComparatorPro",
2397
2551
  }
2552
+
2398
2553
  title = os.path.basename(file_path)
2399
2554
 
2400
- # Create the document using whatever API your DocManager has
2555
+ # --- Create document using whatever DocManager API exists ---
2401
2556
  doc = None
2402
2557
  try:
2403
- if hasattr(dm, "open_array"):
2404
- doc = dm.open_array(np_image_f01, metadata=metadata, title=title)
2558
+ # Preferred: if you have a method that mirrors open_file/load_image shape
2559
+ if hasattr(dm, "open_from_load_image"):
2560
+ # (image, original_header, bit_depth, is_mono, meta)
2561
+ doc = dm.open_from_load_image(np_image_f01, original_header, bit_depth, is_mono, meta, title=title)
2562
+
2563
+ elif hasattr(dm, "open_array"):
2564
+ # Some of your code expects metadata in doc.metadata; pass payload whole
2565
+ doc = dm.open_array(np_image_f01, metadata=payload, title=title)
2566
+
2405
2567
  elif hasattr(dm, "open_numpy"):
2406
- doc = dm.open_numpy(np_image_f01, metadata=metadata, title=title)
2568
+ doc = dm.open_numpy(np_image_f01, metadata=payload, title=title)
2569
+
2407
2570
  elif hasattr(dm, "create_document"):
2408
- doc = dm.create_document(image=np_image_f01, metadata=metadata, name=title)
2571
+ # Try both signatures
2572
+ try:
2573
+ doc = dm.create_document(image=np_image_f01, metadata=payload, name=title)
2574
+ except TypeError:
2575
+ doc = dm.create_document(np_image_f01, payload, title)
2576
+
2409
2577
  else:
2410
- raise AttributeError(self.tr("DocManager lacks open_array/open_numpy/create_document"))
2578
+ raise AttributeError("DocManager lacks a known creation method")
2579
+
2411
2580
  except Exception as e:
2412
2581
  QMessageBox.critical(self, self.tr("Doc Manager"), self.tr("Failed to create document:\n{0}").format(e))
2413
2582
  return
@@ -2416,42 +2585,85 @@ class BlinkTab(QWidget):
2416
2585
  QMessageBox.critical(self, self.tr("Doc Manager"), self.tr("DocManager returned no document."))
2417
2586
  return
2418
2587
 
2419
- # SHOW it: ask the main window to spawn an MDI subwindow
2588
+ # --- Hand off to DocManager flow (DocManager should trigger MDI + window creation) ---
2420
2589
  try:
2421
- mw._spawn_subwindow_for(doc)
2590
+ # If your architecture already auto-spawns windows on documentAdded,
2591
+ # you should NOT call mw._spawn_subwindow_for(doc) here.
2592
+ if hasattr(dm, "add_document"):
2593
+ dm.add_document(doc)
2594
+ elif hasattr(dm, "register_document"):
2595
+ dm.register_document(doc)
2596
+ else:
2597
+ # If open_array/open_numpy already registers the doc internally, do nothing.
2598
+ pass
2599
+
2600
+ # If you *must* spawn manually (older path), keep as fallback
2601
+ if hasattr(mw, "_spawn_subwindow_for"):
2602
+ mw._spawn_subwindow_for(doc)
2603
+
2422
2604
  if hasattr(mw, "_log"):
2423
2605
  mw._log(f"Blink → opened '{title}' as new document")
2606
+
2424
2607
  except Exception as e:
2425
2608
  QMessageBox.critical(self, self.tr("UI"), self.tr("Failed to open subwindow:\n{0}").format(e))
2426
2609
 
2427
2610
 
2611
+
2428
2612
  # optional shim to keep any old calls working
2429
2613
  def push_image_to_manager(self, item):
2430
2614
  self.push_to_docs(item)
2431
2615
 
2432
2616
 
2433
2617
 
2434
- def rename_item(self, item):
2435
- """Allow the user to rename the selected image."""
2436
- current_name = item.text(0).lstrip("⚠️ ")
2437
- new_name, ok = QInputDialog.getText(self, self.tr("Rename Image"), self.tr("Enter new name:"), text=current_name)
2618
+ def rename_item(self, item: QTreeWidgetItem):
2619
+ if not item or item.childCount() > 0:
2620
+ return
2438
2621
 
2439
- if ok and new_name:
2440
- file_path = next((path for path in self.image_paths if os.path.basename(path) == current_name), None)
2441
- if file_path:
2442
- # Get the new file path with the new name
2443
- new_file_path = os.path.join(os.path.dirname(file_path), new_name)
2622
+ idx = self._leaf_index(item)
2623
+ if idx is None:
2624
+ return
2625
+
2626
+ old_path = self.image_paths[idx]
2627
+ old_base = os.path.basename(old_path)
2628
+
2629
+ new_name, ok = QInputDialog.getText(
2630
+ self,
2631
+ self.tr("Rename Image"),
2632
+ self.tr("Enter new name:"),
2633
+ text=old_base
2634
+ )
2635
+ if not ok:
2636
+ return
2637
+
2638
+ new_name = (new_name or "").strip()
2639
+ if not new_name:
2640
+ return
2641
+
2642
+ new_path = os.path.join(os.path.dirname(old_path), new_name)
2643
+
2644
+ # Avoid overwrite
2645
+ if os.path.exists(new_path):
2646
+ QMessageBox.critical(self, self.tr("Error"), self.tr("A file with that name already exists."))
2647
+ return
2648
+
2649
+ try:
2650
+ os.rename(old_path, new_path)
2651
+ except Exception as e:
2652
+ QMessageBox.critical(self, self.tr("Error"), self.tr("Failed to rename the file: {0}").format(e))
2653
+ return
2654
+
2655
+ # Update internal structures
2656
+ self.image_paths[idx] = new_path
2657
+ self.loaded_images[idx]['file_path'] = new_path
2658
+
2659
+ # Update the leaf item
2660
+ flagged = bool(self.loaded_images[idx].get("flagged", False))
2661
+ self._set_leaf_display(item, base_name=new_name, flagged=flagged, full_path=new_path)
2662
+
2663
+ # Rebuild so natural sort stays correct and groups update
2664
+ self._after_list_changed()
2665
+ self._sync_metrics_flags()
2444
2666
 
2445
- try:
2446
- # Rename the file
2447
- os.rename(file_path, new_file_path)
2448
- print(f"File renamed from {current_name} to {new_name}")
2449
-
2450
- # Update the image paths and tree view
2451
- self.image_paths[self.image_paths.index(file_path)] = new_file_path
2452
- item.setText(0, new_name)
2453
- except Exception as e:
2454
- QMessageBox.critical(self, self.tr("Error"), self.tr("Failed to rename the file: {0}").format(e))
2455
2667
 
2456
2668
  def rename_flagged_images(self):
2457
2669
  """Prefix all *flagged* images on disk and in the tree."""
@@ -2562,79 +2774,102 @@ class BlinkTab(QWidget):
2562
2774
 
2563
2775
 
2564
2776
  def batch_rename_items(self):
2565
- """Batch rename selected items by adding a prefix or suffix."""
2566
- selected_items = self.fileTree.selectedItems()
2567
-
2777
+ """Batch rename selected leaf items by adding a prefix and/or suffix."""
2778
+ selected_items = [it for it in self.fileTree.selectedItems() if it and it.childCount() == 0]
2568
2779
  if not selected_items:
2569
- QMessageBox.warning(self, self.tr("Warning"), self.tr("No items selected for renaming."))
2780
+ QMessageBox.warning(self, self.tr("Warning"), self.tr("No individual image items selected for renaming."))
2570
2781
  return
2571
2782
 
2572
- # Create a custom dialog for entering the prefix and suffix
2573
2783
  dialog = QDialog(self)
2574
2784
  dialog.setWindowTitle(self.tr("Batch Rename"))
2575
2785
  dialog_layout = QVBoxLayout(dialog)
2576
2786
 
2577
- instruction_label = QLabel(self.tr("Enter a prefix or suffix to rename selected files:"))
2578
- dialog_layout.addWidget(instruction_label)
2787
+ dialog_layout.addWidget(QLabel(self.tr("Enter a prefix or suffix to rename selected files:"), dialog))
2579
2788
 
2580
- # Create fields for prefix and suffix
2581
2789
  form_layout = QHBoxLayout()
2582
-
2583
2790
  prefix_field = QLineEdit(dialog)
2584
2791
  prefix_field.setPlaceholderText(self.tr("Prefix"))
2585
2792
  form_layout.addWidget(prefix_field)
2586
2793
 
2587
- current_filename_label = QLabel("currentfilename", dialog)
2588
- form_layout.addWidget(current_filename_label)
2794
+ mid_label = QLabel(self.tr("filename"), dialog)
2795
+ mid_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
2796
+ form_layout.addWidget(mid_label)
2589
2797
 
2590
2798
  suffix_field = QLineEdit(dialog)
2591
2799
  suffix_field.setPlaceholderText(self.tr("Suffix"))
2592
2800
  form_layout.addWidget(suffix_field)
2593
-
2594
2801
  dialog_layout.addLayout(form_layout)
2595
2802
 
2596
- # Add OK and Cancel buttons
2597
- button_layout = QHBoxLayout()
2803
+ btns = QHBoxLayout()
2598
2804
  ok_button = QPushButton(self.tr("OK"), dialog)
2599
- ok_button.clicked.connect(dialog.accept)
2600
- button_layout.addWidget(ok_button)
2601
-
2602
2805
  cancel_button = QPushButton(self.tr("Cancel"), dialog)
2806
+ ok_button.clicked.connect(dialog.accept)
2603
2807
  cancel_button.clicked.connect(dialog.reject)
2604
- button_layout.addWidget(cancel_button)
2808
+ btns.addWidget(ok_button)
2809
+ btns.addWidget(cancel_button)
2810
+ dialog_layout.addLayout(btns)
2605
2811
 
2606
- dialog_layout.addLayout(button_layout)
2812
+ if dialog.exec() != QDialog.DialogCode.Accepted:
2813
+ return
2607
2814
 
2608
- # Show the dialog and handle user input
2609
- if dialog.exec() == QDialog.DialogCode.Accepted:
2610
- prefix = prefix_field.text().strip()
2611
- suffix = suffix_field.text().strip()
2815
+ prefix = (prefix_field.text() or "").strip()
2816
+ suffix = (suffix_field.text() or "").strip()
2612
2817
 
2613
- # Rename each selected file
2614
- for item in selected_items:
2615
- current_name = item.text(0)
2616
- file_path = next((path for path in self.image_paths if os.path.basename(path) == current_name), None)
2818
+ if not prefix and not suffix:
2819
+ QMessageBox.information(self, self.tr("Batch Rename"), self.tr("No prefix or suffix entered. Nothing to do."))
2820
+ return
2617
2821
 
2618
- if file_path:
2619
- # Construct the new filename
2620
- directory = os.path.dirname(file_path)
2621
- new_name = f"{prefix}{current_name}{suffix}"
2622
- new_file_path = os.path.join(directory, new_name)
2822
+ renamed = 0
2823
+ failures = []
2623
2824
 
2624
- try:
2625
- # Rename the file
2626
- os.rename(file_path, new_file_path)
2627
- print(f"File renamed from {file_path} to {new_file_path}")
2825
+ # Work on indices so we can update lists safely
2826
+ indices = []
2827
+ for it in selected_items:
2828
+ idx = self._leaf_index(it)
2829
+ if idx is not None:
2830
+ indices.append((idx, it))
2628
2831
 
2629
- # Update the paths and tree view
2630
- self.image_paths[self.image_paths.index(file_path)] = new_file_path
2631
- item.setText(0, new_name)
2832
+ for idx, it in indices:
2833
+ old_path = self.image_paths[idx]
2834
+ directory, base = os.path.split(old_path)
2632
2835
 
2633
- except Exception as e:
2634
- print(f"Failed to rename {file_path}: {e}")
2635
- QMessageBox.critical(self, self.tr("Error"), self.tr("Failed to rename the file: {0}").format(e))
2836
+ new_base = f"{prefix}{base}{suffix}"
2837
+ new_path = os.path.join(directory, new_base)
2838
+
2839
+ if new_path == old_path:
2840
+ continue
2841
+
2842
+ if os.path.exists(new_path):
2843
+ failures.append((old_path, self.tr("target already exists")))
2844
+ continue
2845
+
2846
+ try:
2847
+ os.rename(old_path, new_path)
2848
+ except Exception as e:
2849
+ failures.append((old_path, str(e)))
2850
+ continue
2851
+
2852
+ # Update internal lists
2853
+ self.image_paths[idx] = new_path
2854
+ self.loaded_images[idx]["file_path"] = new_path
2855
+
2856
+ # Update leaf item
2857
+ flagged = bool(self.loaded_images[idx].get("flagged", False))
2858
+ self._set_leaf_display(it, base_name=new_base, flagged=flagged, full_path=new_path)
2859
+
2860
+ renamed += 1
2861
+
2862
+ # Rebuild so group headers + natural order stay correct
2863
+ self._after_list_changed()
2864
+ self._sync_metrics_flags()
2865
+
2866
+ msg = self.tr("Batch renamed {0} file{1}.").format(renamed, "s" if renamed != 1 else "")
2867
+ if failures:
2868
+ msg += self.tr("\n\n{0} file(s) failed:").format(len(failures))
2869
+ for old, err in failures[:10]:
2870
+ msg += f"\n• {os.path.basename(old)} – {err}"
2871
+ QMessageBox.information(self, self.tr("Batch Rename"), msg)
2636
2872
 
2637
- print(f"Batch renamed {len(selected_items)} items.")
2638
2873
 
2639
2874
  def batch_delete_flagged_images(self):
2640
2875
  """Delete all flagged images."""
@@ -2681,141 +2916,181 @@ class BlinkTab(QWidget):
2681
2916
  self._after_list_changed(removed_indices)
2682
2917
 
2683
2918
  def batch_move_flagged_images(self):
2684
- """Move all flagged images to a selected directory."""
2685
- flagged_images = [img for img in self.loaded_images if img['flagged']]
2686
-
2687
- if not flagged_images:
2919
+ """Move all flagged images to a selected directory AND remove them from the blink list."""
2920
+ flagged_indices = [i for i, e in enumerate(self.loaded_images) if e.get("flagged", False)]
2921
+ if not flagged_indices:
2688
2922
  QMessageBox.information(self, self.tr("No Flagged Images"), self.tr("There are no flagged images to move."))
2689
2923
  return
2690
2924
 
2691
- # Select destination directory
2692
2925
  destination_dir = QFileDialog.getExistingDirectory(self, self.tr("Select Destination Folder"), "")
2693
2926
  if not destination_dir:
2694
- return # User canceled
2927
+ return
2695
2928
 
2696
- for img in flagged_images:
2697
- src_path = img['file_path']
2698
- file_name = os.path.basename(src_path)
2699
- dest_path = os.path.join(destination_dir, file_name)
2929
+ failures = []
2700
2930
 
2931
+ # Move first (use current paths from indices)
2932
+ for i in flagged_indices:
2933
+ src_path = self.image_paths[i]
2934
+ dest_path = os.path.join(destination_dir, os.path.basename(src_path))
2701
2935
  try:
2702
2936
  os.rename(src_path, dest_path)
2703
- print(f"Moved flagged image from {src_path} to {dest_path}")
2704
2937
  except Exception as e:
2705
- print(f"Failed to move {src_path}: {e}")
2706
- QMessageBox.critical(self, self.tr("Error"), self.tr("Failed to move {0}: {1}").format(src_path, e))
2707
- continue
2938
+ failures.append((src_path, str(e)))
2939
+
2940
+ # Remove from lists ONLY if move succeeded
2941
+ # Build a set of indices to remove: those that did NOT fail
2942
+ failed_src = {p for p, _ in failures}
2943
+ removed_indices = [i for i in flagged_indices if self.image_paths[i] not in failed_src]
2708
2944
 
2709
- # Update data structures
2710
- self.image_paths.remove(src_path)
2711
- self.image_paths.append(dest_path)
2712
- img['file_path'] = dest_path
2713
- img['flagged'] = False # Reset flag if desired
2945
+ removed_indices = sorted(set(removed_indices), reverse=True)
2946
+ for idx in removed_indices:
2947
+ if 0 <= idx < len(self.image_paths):
2948
+ del self.image_paths[idx]
2949
+ if 0 <= idx < len(self.loaded_images):
2950
+ del self.loaded_images[idx]
2714
2951
 
2715
- # Update tree view
2716
- self.remove_item_from_tree(src_path)
2717
- self.add_item_to_tree(dest_path)
2952
+ if removed_indices:
2953
+ self._after_list_changed(removed_indices)
2954
+
2955
+ if failures:
2956
+ msg = self.tr("Moved {0} flagged file(s). {1} failed:").format(len(removed_indices), len(failures))
2957
+ for p, err in failures[:10]:
2958
+ msg += f"\n• {os.path.basename(p)} – {err}"
2959
+ QMessageBox.warning(self, self.tr("Batch Move"), msg)
2960
+ else:
2961
+ QMessageBox.information(self, self.tr("Batch Move"), self.tr("Moved and removed {0} flagged image(s).").format(len(removed_indices)))
2718
2962
 
2719
- QMessageBox.information(self, self.tr("Batch Move"), self.tr("Moved {0} flagged images.").format(len(flagged_images)))
2720
- self._after_list_changed(removed_indices=None)
2721
2963
 
2722
2964
  def move_items(self):
2723
- """Move selected images *and* remove them from the tree+metrics."""
2724
- selected_items = self.fileTree.selectedItems()
2965
+ """Move selected leaf images to a selected directory AND remove them from the blink list."""
2966
+ selected_items = [it for it in self.fileTree.selectedItems() if it and it.childCount() == 0]
2725
2967
  if not selected_items:
2726
- QMessageBox.warning(self, self.tr("Warning"), self.tr("No items selected for moving."))
2968
+ QMessageBox.warning(self, self.tr("Warning"), self.tr("No individual image items selected for moving."))
2727
2969
  return
2728
2970
 
2729
- # Ask where to move
2730
- new_dir = QFileDialog.getExistingDirectory(self,
2731
- self.tr("Select Destination Folder"),
2732
- "")
2971
+ new_dir = QFileDialog.getExistingDirectory(self, self.tr("Select Destination Folder"), "")
2733
2972
  if not new_dir:
2734
2973
  return
2735
2974
 
2736
- # Keep track of which on‐disk paths we actually moved
2737
- moved_old_paths = []
2738
2975
  removed_indices = []
2976
+ failures = []
2739
2977
 
2740
- for item in selected_items:
2741
- name = item.text(0).lstrip("⚠️ ")
2742
- old_path = next((p for p in self.image_paths
2743
- if os.path.basename(p) == name), None)
2744
- if not old_path:
2978
+ # Collect (idx, old_path, item) first to avoid index drift
2979
+ triplets = []
2980
+ for it in selected_items:
2981
+ p = self._leaf_path(it)
2982
+ if not p:
2983
+ continue
2984
+ try:
2985
+ idx = self.image_paths.index(p)
2986
+ except ValueError:
2745
2987
  continue
2746
- removed_indices.append(self.image_paths.index(old_path))
2988
+ triplets.append((idx, p, it))
2747
2989
 
2748
- new_path = os.path.join(new_dir, name)
2990
+ for idx, old_path, it in triplets:
2991
+ base = os.path.basename(old_path)
2992
+ new_path = os.path.join(new_dir, base)
2749
2993
  try:
2750
2994
  os.rename(old_path, new_path)
2751
2995
  except Exception as e:
2752
- QMessageBox.critical(self, self.tr("Error"), self.tr("Failed to move {0}: {1}").format(old_path, e))
2996
+ failures.append((old_path, str(e)))
2753
2997
  continue
2754
2998
 
2755
- moved_old_paths.append(old_path)
2756
-
2757
- # 1) Remove the leaf from the tree
2758
- parent = item.parent() or self.fileTree.invisibleRootItem()
2759
- parent.removeChild(item)
2999
+ removed_indices.append(idx)
2760
3000
 
2761
- # 2) Purge them from your internal lists
2762
- for idx in sorted(removed_indices, reverse=True):
2763
- del self.image_paths[idx]
2764
- del self.loaded_images[idx]
3001
+ # remove leaf from tree immediately (optional; _after_list_changed will rebuild anyway)
3002
+ #parent = it.parent() or self.fileTree.invisibleRootItem()
3003
+ #parent.removeChild(it)
2765
3004
 
2766
- self._after_list_changed(removed_indices)
2767
- print(f"Moved and removed {len(removed_indices)} items.")
3005
+ # Purge arrays descending
3006
+ removed_indices = sorted(set(removed_indices), reverse=True)
3007
+ for idx in removed_indices:
3008
+ if 0 <= idx < len(self.image_paths):
3009
+ del self.image_paths[idx]
3010
+ if 0 <= idx < len(self.loaded_images):
3011
+ del self.loaded_images[idx]
2768
3012
 
3013
+ if removed_indices:
3014
+ self._after_list_changed(removed_indices)
2769
3015
 
3016
+ if failures:
3017
+ msg = self.tr("Moved {0} file(s). {1} failed:").format(len(removed_indices), len(failures))
3018
+ for old, err in failures[:10]:
3019
+ msg += f"\n• {os.path.basename(old)} – {err}"
3020
+ QMessageBox.warning(self, self.tr("Move Selected Items"), msg)
3021
+ else:
3022
+ QMessageBox.information(self, self.tr("Move Selected Items"), self.tr("Moved and removed {0} item(s).").format(len(removed_indices)))
2770
3023
 
2771
3024
  def delete_items(self):
2772
- """Delete the selected items from the tree, the loaded images list, and the file system."""
2773
- selected_items = self.fileTree.selectedItems()
2774
-
3025
+ """Delete selected leaf images from disk and remove them from the blink list."""
3026
+ selected_items = [it for it in self.fileTree.selectedItems() if it and it.childCount() == 0]
2775
3027
  if not selected_items:
2776
- QMessageBox.warning(self, self.tr("Warning"), self.tr("No items selected for deletion."))
3028
+ QMessageBox.warning(self, self.tr("Warning"), self.tr("No individual image items selected for deletion."))
2777
3029
  return
2778
3030
 
2779
- # Confirmation dialog
2780
3031
  reply = QMessageBox.question(
2781
3032
  self,
2782
- self.tr('Confirm Deletion'),
3033
+ self.tr("Confirm Deletion"),
2783
3034
  self.tr("Are you sure you want to permanently delete {0} selected images? This action is irreversible.").format(len(selected_items)),
2784
3035
  QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
2785
3036
  QMessageBox.StandardButton.No
2786
3037
  )
3038
+ if reply != QMessageBox.StandardButton.Yes:
3039
+ return
2787
3040
 
2788
3041
  removed_indices = []
2789
- if reply == QMessageBox.StandardButton.Yes:
2790
- for item in selected_items:
2791
- file_name = item.text(0).lstrip("⚠️ ")
2792
- file_path = next((path for path in self.image_paths if os.path.basename(path) == file_name), None)
2793
- if file_path:
2794
- try:
2795
- idx = self.image_paths.index(file_path)
2796
- removed_indices.append(idx) # collect BEFORE mutation
2797
- ...
2798
- os.remove(file_path)
2799
- except Exception as e:
2800
- ...
2801
- # Remove from widgets
2802
- for item in selected_items:
2803
- parent = item.parent() or self.fileTree.invisibleRootItem()
2804
- parent.removeChild(item)
2805
-
2806
- # Purge arrays (descending order)
2807
- for idx in sorted(removed_indices, reverse=True):
3042
+ failures = []
3043
+
3044
+ # Snapshot first
3045
+ triplets = []
3046
+ for it in selected_items:
3047
+ p = self._leaf_path(it)
3048
+ if not p:
3049
+ continue
3050
+ try:
3051
+ idx = self.image_paths.index(p)
3052
+ except ValueError:
3053
+ continue
3054
+ triplets.append((idx, p, it))
3055
+
3056
+ for idx, path, it in triplets:
3057
+ try:
3058
+ os.remove(path)
3059
+ except Exception as e:
3060
+ failures.append((path, str(e)))
3061
+ continue
3062
+
3063
+ removed_indices.append(idx)
3064
+
3065
+ # remove from tree immediately (optional)
3066
+ parent = it.parent() or self.fileTree.invisibleRootItem()
3067
+ parent.removeChild(it)
3068
+
3069
+ # Purge arrays descending
3070
+ removed_indices = sorted(set(removed_indices), reverse=True)
3071
+ for idx in removed_indices:
3072
+ if 0 <= idx < len(self.image_paths):
2808
3073
  del self.image_paths[idx]
3074
+ if 0 <= idx < len(self.loaded_images):
2809
3075
  del self.loaded_images[idx]
2810
3076
 
2811
- # Clear preview
2812
- self.preview_label.clear()
2813
- self.preview_label.setText(self.tr('No image selected.'))
2814
- self.current_image = None
3077
+ # Clear preview safely
3078
+ self.preview_label.clear()
3079
+ self.preview_label.setText(self.tr("No image selected."))
3080
+ self.current_pixmap = None
2815
3081
 
2816
- # 🔁 refresh tree + metrics (no recompute)
3082
+ if removed_indices:
2817
3083
  self._after_list_changed(removed_indices)
2818
3084
 
3085
+ if failures:
3086
+ msg = self.tr("Deleted {0} file(s). {1} failed:").format(len(removed_indices), len(failures))
3087
+ for p, err in failures[:10]:
3088
+ msg += f"\n• {os.path.basename(p)} – {err}"
3089
+ QMessageBox.warning(self, self.tr("Delete Selected Items"), msg)
3090
+ else:
3091
+ QMessageBox.information(self, self.tr("Delete Selected Items"), self.tr("Deleted {0} item(s).").format(len(removed_indices)))
3092
+
3093
+
2819
3094
  def eventFilter(self, source, event):
2820
3095
  """Handle mouse events for dragging."""
2821
3096
  if source == self.scroll_area.viewport():
@@ -2878,16 +3153,14 @@ class BlinkTab(QWidget):
2878
3153
  self.on_item_clicked(cur, 0)
2879
3154
 
2880
3155
  def convert_to_qimage(self, img_array):
2881
- """Convert numpy image array to QImage."""
2882
- # 1) Bring everything into a uint8 (0–255) array
2883
3156
  if img_array.dtype == np.uint8:
2884
3157
  arr8 = img_array
2885
3158
  elif img_array.dtype == np.uint16:
2886
- # downscale 16-bit → 8-bit
2887
3159
  arr8 = (img_array.astype(np.float32) / 65535.0 * 255.0).clip(0,255).astype(np.uint8)
2888
3160
  else:
2889
- # assume float in [0..1]
2890
- arr8 = (img_array.clip(0.0, 1.0) * 255.0).astype(np.uint8)
3161
+ # display-only normalize floats outside 0..1
3162
+ f01 = self._ensure_float01(img_array)
3163
+ arr8 = (f01 * 255.0).astype(np.uint8)
2891
3164
 
2892
3165
  h, w = arr8.shape[:2]
2893
3166
  buffer = arr8.tobytes()