setiastrosuitepro 1.6.0__py3-none-any.whl → 1.6.4.post1__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 (293) hide show
  1. setiastro/data/SASP_data.fits +0 -0
  2. setiastro/data/catalogs/List_of_Galaxies_with_Distances_Gly.csv +488 -0
  3. setiastro/data/catalogs/astrobin_filters.csv +890 -0
  4. setiastro/data/catalogs/astrobin_filters_page1_local.csv +51 -0
  5. setiastro/data/catalogs/cali2.csv +63 -0
  6. setiastro/data/catalogs/cali2color.csv +65 -0
  7. setiastro/data/catalogs/celestial_catalog - original.csv +16471 -0
  8. setiastro/data/catalogs/celestial_catalog.csv +24031 -0
  9. setiastro/data/catalogs/detected_stars.csv +24784 -0
  10. setiastro/data/catalogs/fits_header_data.csv +46 -0
  11. setiastro/data/catalogs/test.csv +8 -0
  12. setiastro/data/catalogs/updated_celestial_catalog.csv +16471 -0
  13. setiastro/images/Astro_Spikes.png +0 -0
  14. setiastro/images/Background_startup.jpg +0 -0
  15. setiastro/images/HRDiagram.png +0 -0
  16. setiastro/images/LExtract.png +0 -0
  17. setiastro/images/LInsert.png +0 -0
  18. setiastro/images/Oxygenation-atm-2.svg.png +0 -0
  19. setiastro/images/RGB080604.png +0 -0
  20. setiastro/images/abeicon.png +0 -0
  21. setiastro/images/aberration.png +0 -0
  22. setiastro/images/andromedatry.png +0 -0
  23. setiastro/images/andromedatry_satellited.png +0 -0
  24. setiastro/images/annotated.png +0 -0
  25. setiastro/images/aperture.png +0 -0
  26. setiastro/images/astrosuite.ico +0 -0
  27. setiastro/images/astrosuite.png +0 -0
  28. setiastro/images/astrosuitepro.icns +0 -0
  29. setiastro/images/astrosuitepro.ico +0 -0
  30. setiastro/images/astrosuitepro.png +0 -0
  31. setiastro/images/background.png +0 -0
  32. setiastro/images/background2.png +0 -0
  33. setiastro/images/benchmark.png +0 -0
  34. setiastro/images/big_moon_stabilizer_timeline.png +0 -0
  35. setiastro/images/big_moon_stabilizer_timeline_clean.png +0 -0
  36. setiastro/images/blaster.png +0 -0
  37. setiastro/images/blink.png +0 -0
  38. setiastro/images/clahe.png +0 -0
  39. setiastro/images/collage.png +0 -0
  40. setiastro/images/colorwheel.png +0 -0
  41. setiastro/images/contsub.png +0 -0
  42. setiastro/images/convo.png +0 -0
  43. setiastro/images/copyslot.png +0 -0
  44. setiastro/images/cosmic.png +0 -0
  45. setiastro/images/cosmicsat.png +0 -0
  46. setiastro/images/crop1.png +0 -0
  47. setiastro/images/cropicon.png +0 -0
  48. setiastro/images/curves.png +0 -0
  49. setiastro/images/cvs.png +0 -0
  50. setiastro/images/debayer.png +0 -0
  51. setiastro/images/denoise_cnn_custom.png +0 -0
  52. setiastro/images/denoise_cnn_graph.png +0 -0
  53. setiastro/images/disk.png +0 -0
  54. setiastro/images/dse.png +0 -0
  55. setiastro/images/exoicon.png +0 -0
  56. setiastro/images/eye.png +0 -0
  57. setiastro/images/fliphorizontal.png +0 -0
  58. setiastro/images/flipvertical.png +0 -0
  59. setiastro/images/font.png +0 -0
  60. setiastro/images/freqsep.png +0 -0
  61. setiastro/images/functionbundle.png +0 -0
  62. setiastro/images/graxpert.png +0 -0
  63. setiastro/images/green.png +0 -0
  64. setiastro/images/gridicon.png +0 -0
  65. setiastro/images/halo.png +0 -0
  66. setiastro/images/hdr.png +0 -0
  67. setiastro/images/histogram.png +0 -0
  68. setiastro/images/hubble.png +0 -0
  69. setiastro/images/imagecombine.png +0 -0
  70. setiastro/images/invert.png +0 -0
  71. setiastro/images/isophote.png +0 -0
  72. setiastro/images/isophote_demo_figure.png +0 -0
  73. setiastro/images/isophote_demo_image.png +0 -0
  74. setiastro/images/isophote_demo_model.png +0 -0
  75. setiastro/images/isophote_demo_residual.png +0 -0
  76. setiastro/images/jwstpupil.png +0 -0
  77. setiastro/images/linearfit.png +0 -0
  78. setiastro/images/livestacking.png +0 -0
  79. setiastro/images/mask.png +0 -0
  80. setiastro/images/maskapply.png +0 -0
  81. setiastro/images/maskcreate.png +0 -0
  82. setiastro/images/maskremove.png +0 -0
  83. setiastro/images/morpho.png +0 -0
  84. setiastro/images/mosaic.png +0 -0
  85. setiastro/images/multiscale_decomp.png +0 -0
  86. setiastro/images/nbtorgb.png +0 -0
  87. setiastro/images/neutral.png +0 -0
  88. setiastro/images/nuke.png +0 -0
  89. setiastro/images/openfile.png +0 -0
  90. setiastro/images/pedestal.png +0 -0
  91. setiastro/images/pen.png +0 -0
  92. setiastro/images/pixelmath.png +0 -0
  93. setiastro/images/platesolve.png +0 -0
  94. setiastro/images/ppp.png +0 -0
  95. setiastro/images/pro.png +0 -0
  96. setiastro/images/project.png +0 -0
  97. setiastro/images/psf.png +0 -0
  98. setiastro/images/redo.png +0 -0
  99. setiastro/images/redoicon.png +0 -0
  100. setiastro/images/rescale.png +0 -0
  101. setiastro/images/rgbalign.png +0 -0
  102. setiastro/images/rgbcombo.png +0 -0
  103. setiastro/images/rgbextract.png +0 -0
  104. setiastro/images/rotate180.png +0 -0
  105. setiastro/images/rotatearbitrary.png +0 -0
  106. setiastro/images/rotateclockwise.png +0 -0
  107. setiastro/images/rotatecounterclockwise.png +0 -0
  108. setiastro/images/satellite.png +0 -0
  109. setiastro/images/script.png +0 -0
  110. setiastro/images/selectivecolor.png +0 -0
  111. setiastro/images/simbad.png +0 -0
  112. setiastro/images/slot0.png +0 -0
  113. setiastro/images/slot1.png +0 -0
  114. setiastro/images/slot2.png +0 -0
  115. setiastro/images/slot3.png +0 -0
  116. setiastro/images/slot4.png +0 -0
  117. setiastro/images/slot5.png +0 -0
  118. setiastro/images/slot6.png +0 -0
  119. setiastro/images/slot7.png +0 -0
  120. setiastro/images/slot8.png +0 -0
  121. setiastro/images/slot9.png +0 -0
  122. setiastro/images/spcc.png +0 -0
  123. setiastro/images/spin_precession_vs_lunar_distance.png +0 -0
  124. setiastro/images/spinner.gif +0 -0
  125. setiastro/images/stacking.png +0 -0
  126. setiastro/images/staradd.png +0 -0
  127. setiastro/images/staralign.png +0 -0
  128. setiastro/images/starnet.png +0 -0
  129. setiastro/images/starregistration.png +0 -0
  130. setiastro/images/starspike.png +0 -0
  131. setiastro/images/starstretch.png +0 -0
  132. setiastro/images/statstretch.png +0 -0
  133. setiastro/images/supernova.png +0 -0
  134. setiastro/images/uhs.png +0 -0
  135. setiastro/images/undoicon.png +0 -0
  136. setiastro/images/upscale.png +0 -0
  137. setiastro/images/viewbundle.png +0 -0
  138. setiastro/images/whitebalance.png +0 -0
  139. setiastro/images/wimi_icon_256x256.png +0 -0
  140. setiastro/images/wimilogo.png +0 -0
  141. setiastro/images/wims.png +0 -0
  142. setiastro/images/wrench_icon.png +0 -0
  143. setiastro/images/xisfliberator.png +0 -0
  144. setiastro/qml/ResourceMonitor.qml +126 -0
  145. setiastro/saspro/__main__.py +228 -67
  146. setiastro/saspro/_generated/build_info.py +2 -1
  147. setiastro/saspro/abe.py +76 -25
  148. setiastro/saspro/aberration_ai.py +14 -14
  149. setiastro/saspro/add_stars.py +15 -12
  150. setiastro/saspro/astrobin_exporter.py +61 -58
  151. setiastro/saspro/astrospike_python.py +3 -1
  152. setiastro/saspro/autostretch.py +4 -2
  153. setiastro/saspro/backgroundneutral.py +65 -14
  154. setiastro/saspro/batch_convert.py +8 -5
  155. setiastro/saspro/batch_renamer.py +39 -36
  156. setiastro/saspro/blemish_blaster.py +15 -12
  157. setiastro/saspro/blink_comparator_pro.py +605 -379
  158. setiastro/saspro/cheat_sheet.py +62 -17
  159. setiastro/saspro/clahe.py +34 -8
  160. setiastro/saspro/comet_stacking.py +103 -38
  161. setiastro/saspro/common_tr.py +107 -0
  162. setiastro/saspro/continuum_subtract.py +7 -7
  163. setiastro/saspro/convo.py +12 -9
  164. setiastro/saspro/copyastro.py +3 -0
  165. setiastro/saspro/cosmicclarity.py +77 -52
  166. setiastro/saspro/crop_dialog_pro.py +80 -45
  167. setiastro/saspro/curve_editor_pro.py +51 -33
  168. setiastro/saspro/debayer.py +6 -3
  169. setiastro/saspro/doc_manager.py +49 -19
  170. setiastro/saspro/exoplanet_detector.py +11 -11
  171. setiastro/saspro/fitsmodifier.py +48 -44
  172. setiastro/saspro/fix_bom.py +32 -0
  173. setiastro/saspro/frequency_separation.py +18 -12
  174. setiastro/saspro/function_bundle.py +18 -16
  175. setiastro/saspro/generate_translations.py +3092 -0
  176. setiastro/saspro/ghs_dialog_pro.py +19 -16
  177. setiastro/saspro/graxpert.py +3 -0
  178. setiastro/saspro/gui/main_window.py +471 -126
  179. setiastro/saspro/gui/mixins/dock_mixin.py +123 -11
  180. setiastro/saspro/gui/mixins/file_mixin.py +25 -20
  181. setiastro/saspro/gui/mixins/geometry_mixin.py +115 -15
  182. setiastro/saspro/gui/mixins/header_mixin.py +6 -6
  183. setiastro/saspro/gui/mixins/mask_mixin.py +8 -8
  184. setiastro/saspro/gui/mixins/menu_mixin.py +62 -33
  185. setiastro/saspro/gui/mixins/toolbar_mixin.py +382 -226
  186. setiastro/saspro/gui/mixins/update_mixin.py +26 -26
  187. setiastro/saspro/gui/statistics_dialog.py +47 -0
  188. setiastro/saspro/halobgon.py +29 -3
  189. setiastro/saspro/header_viewer.py +21 -18
  190. setiastro/saspro/histogram.py +29 -26
  191. setiastro/saspro/history_explorer.py +2 -0
  192. setiastro/saspro/i18n.py +168 -0
  193. setiastro/saspro/image_combine.py +3 -0
  194. setiastro/saspro/image_peeker_pro.py +52 -44
  195. setiastro/saspro/imageops/stretch.py +5 -13
  196. setiastro/saspro/isophote.py +3 -0
  197. setiastro/saspro/legacy/numba_utils.py +64 -47
  198. setiastro/saspro/linear_fit.py +3 -0
  199. setiastro/saspro/live_stacking.py +13 -2
  200. setiastro/saspro/mask_creation.py +180 -22
  201. setiastro/saspro/mfdeconv.py +5 -0
  202. setiastro/saspro/morphology.py +38 -13
  203. setiastro/saspro/multiscale_decomp.py +713 -256
  204. setiastro/saspro/nbtorgb_stars.py +12 -2
  205. setiastro/saspro/numba_utils.py +149 -48
  206. setiastro/saspro/ops/scripts.py +77 -17
  207. setiastro/saspro/ops/settings.py +177 -100
  208. setiastro/saspro/perfect_palette_picker.py +25 -7
  209. setiastro/saspro/pixelmath.py +114 -110
  210. setiastro/saspro/plate_solver.py +118 -108
  211. setiastro/saspro/remove_green.py +24 -7
  212. setiastro/saspro/remove_stars.py +136 -162
  213. setiastro/saspro/remove_stars_preset.py +55 -13
  214. setiastro/saspro/resources.py +46 -15
  215. setiastro/saspro/rgb_combination.py +19 -18
  216. setiastro/saspro/rgbalign.py +11 -11
  217. setiastro/saspro/save_options.py +5 -4
  218. setiastro/saspro/selective_color.py +84 -25
  219. setiastro/saspro/sfcc.py +119 -72
  220. setiastro/saspro/shortcuts.py +345 -36
  221. setiastro/saspro/signature_insert.py +4 -1
  222. setiastro/saspro/stacking_suite.py +2066 -1119
  223. setiastro/saspro/star_alignment.py +291 -331
  224. setiastro/saspro/star_spikes.py +137 -53
  225. setiastro/saspro/star_stretch.py +47 -10
  226. setiastro/saspro/stat_stretch.py +52 -16
  227. setiastro/saspro/status_log_dock.py +1 -1
  228. setiastro/saspro/subwindow.py +97 -36
  229. setiastro/saspro/supernovaasteroidhunter.py +68 -61
  230. setiastro/saspro/swap_manager.py +77 -42
  231. setiastro/saspro/translations/all_source_strings.json +4726 -0
  232. setiastro/saspro/translations/ar_translations.py +4096 -0
  233. setiastro/saspro/translations/de_translations.py +3728 -0
  234. setiastro/saspro/translations/es_translations.py +4169 -0
  235. setiastro/saspro/translations/fr_translations.py +4090 -0
  236. setiastro/saspro/translations/hi_translations.py +3803 -0
  237. setiastro/saspro/translations/integrate_translations.py +271 -0
  238. setiastro/saspro/translations/it_translations.py +4728 -0
  239. setiastro/saspro/translations/ja_translations.py +3834 -0
  240. setiastro/saspro/translations/pt_translations.py +3847 -0
  241. setiastro/saspro/translations/ru_translations.py +3082 -0
  242. setiastro/saspro/translations/saspro_ar.qm +0 -0
  243. setiastro/saspro/translations/saspro_ar.ts +16019 -0
  244. setiastro/saspro/translations/saspro_de.qm +0 -0
  245. setiastro/saspro/translations/saspro_de.ts +14548 -0
  246. setiastro/saspro/translations/saspro_es.qm +0 -0
  247. setiastro/saspro/translations/saspro_es.ts +16202 -0
  248. setiastro/saspro/translations/saspro_fr.qm +0 -0
  249. setiastro/saspro/translations/saspro_fr.ts +15870 -0
  250. setiastro/saspro/translations/saspro_hi.qm +0 -0
  251. setiastro/saspro/translations/saspro_hi.ts +14855 -0
  252. setiastro/saspro/translations/saspro_it.qm +0 -0
  253. setiastro/saspro/translations/saspro_it.ts +19046 -0
  254. setiastro/saspro/translations/saspro_ja.qm +0 -0
  255. setiastro/saspro/translations/saspro_ja.ts +14980 -0
  256. setiastro/saspro/translations/saspro_pt.qm +0 -0
  257. setiastro/saspro/translations/saspro_pt.ts +15024 -0
  258. setiastro/saspro/translations/saspro_ru.qm +0 -0
  259. setiastro/saspro/translations/saspro_ru.ts +11835 -0
  260. setiastro/saspro/translations/saspro_sw.qm +0 -0
  261. setiastro/saspro/translations/saspro_sw.ts +15237 -0
  262. setiastro/saspro/translations/saspro_uk.qm +0 -0
  263. setiastro/saspro/translations/saspro_uk.ts +15248 -0
  264. setiastro/saspro/translations/saspro_zh.qm +0 -0
  265. setiastro/saspro/translations/saspro_zh.ts +15289 -0
  266. setiastro/saspro/translations/sw_translations.py +3897 -0
  267. setiastro/saspro/translations/uk_translations.py +3929 -0
  268. setiastro/saspro/translations/zh_translations.py +3910 -0
  269. setiastro/saspro/versioning.py +77 -0
  270. setiastro/saspro/view_bundle.py +20 -17
  271. setiastro/saspro/wavescale_hdr.py +54 -33
  272. setiastro/saspro/wavescale_hdr_preset.py +6 -5
  273. setiastro/saspro/wavescalede.py +54 -31
  274. setiastro/saspro/wavescalede_preset.py +9 -7
  275. setiastro/saspro/whitebalance.py +58 -22
  276. setiastro/saspro/widgets/common_utilities.py +12 -11
  277. setiastro/saspro/widgets/minigame/game.js +991 -0
  278. setiastro/saspro/widgets/minigame/index.html +53 -0
  279. setiastro/saspro/widgets/minigame/style.css +241 -0
  280. setiastro/saspro/widgets/preview_dialogs.py +8 -8
  281. setiastro/saspro/widgets/resource_monitor.py +263 -0
  282. setiastro/saspro/widgets/spinboxes.py +18 -0
  283. setiastro/saspro/widgets/wavelet_utils.py +52 -20
  284. setiastro/saspro/wimi.py +7996 -0
  285. setiastro/saspro/wims.py +578 -0
  286. setiastro/saspro/window_shelf.py +2 -2
  287. {setiastrosuitepro-1.6.0.dist-info → setiastrosuitepro-1.6.4.post1.dist-info}/METADATA +15 -3
  288. setiastrosuitepro-1.6.4.post1.dist-info/RECORD +368 -0
  289. setiastrosuitepro-1.6.0.dist-info/RECORD +0 -174
  290. {setiastrosuitepro-1.6.0.dist-info → setiastrosuitepro-1.6.4.post1.dist-info}/WHEEL +0 -0
  291. {setiastrosuitepro-1.6.0.dist-info → setiastrosuitepro-1.6.4.post1.dist-info}/entry_points.txt +0 -0
  292. {setiastrosuitepro-1.6.0.dist-info → setiastrosuitepro-1.6.4.post1.dist-info}/licenses/LICENSE +0 -0
  293. {setiastrosuitepro-1.6.0.dist-info → setiastrosuitepro-1.6.4.post1.dist-info}/licenses/license.txt +0 -0
@@ -12,7 +12,7 @@ from concurrent.futures import ThreadPoolExecutor, as_completed
12
12
  from typing import Optional, List
13
13
  from collections import defaultdict
14
14
  # Qt
15
- from PyQt6.QtCore import Qt, QTimer, QEvent, QPointF, QRectF, pyqtSignal, QSettings, QPoint
15
+ from PyQt6.QtCore import Qt, QTimer, QEvent, QPointF, QRectF, pyqtSignal, QSettings, QPoint, QCoreApplication
16
16
  from PyQt6.QtGui import (QAction, QIcon, QImage, QPixmap, QBrush, QColor, QPalette,
17
17
  QKeySequence, QWheelEvent, QShortcut, QDoubleValidator, QIntValidator)
18
18
  from PyQt6.QtWidgets import (
@@ -65,7 +65,7 @@ class MetricsPanel(QWidget):
65
65
  self._open_previews = []
66
66
 
67
67
  self.plots, self.scats, self.lines = [], [], []
68
- titles = ["FWHM (px)", "Eccentricity", "Background", "Star Count"]
68
+ titles = [self.tr("FWHM (px)"), self.tr("Eccentricity"), self.tr("Background"), self.tr("Star Count")]
69
69
  for idx, title in enumerate(titles):
70
70
  pw = pg.PlotWidget()
71
71
  pw.setTitle(title)
@@ -167,14 +167,14 @@ class MetricsPanel(QWidget):
167
167
  show = settings.value("metrics/showWarning", True, type=bool)
168
168
  if show:
169
169
  msg = QMessageBox(self)
170
- msg.setWindowTitle("Heads-up")
171
- msg.setText(
170
+ msg.setWindowTitle(self.tr("Heads-up"))
171
+ msg.setText(self.tr(
172
172
  "This is going to use ALL your CPU cores and the UI may lock up until it finishes.\n\n"
173
173
  "Continue?"
174
- )
174
+ ))
175
175
  msg.setStandardButtons(QMessageBox.StandardButton.Yes |
176
176
  QMessageBox.StandardButton.No)
177
- cb = QCheckBox("Don't show again", msg)
177
+ cb = QCheckBox(self.tr("Don't show again"), msg)
178
178
  msg.setCheckBox(cb)
179
179
  if msg.exec() != QMessageBox.StandardButton.Yes:
180
180
  return
@@ -189,7 +189,7 @@ class MetricsPanel(QWidget):
189
189
  flags = [e.get('flagged', False) for e in loaded_images]
190
190
 
191
191
  # progress dialog
192
- prog = QProgressDialog("Computing frame metrics…", "Cancel", 0, n, self)
192
+ prog = QProgressDialog(self.tr("Computing frame metrics…"), self.tr("Cancel"), 0, n, self)
193
193
  prog.setWindowModality(Qt.WindowModality.WindowModal)
194
194
  prog.setMinimumDuration(0)
195
195
  prog.setValue(0)
@@ -343,18 +343,19 @@ class MetricsWindow(QWidget):
343
343
  def __init__(self, parent=None):
344
344
  super().__init__(parent, Qt.WindowType.Window)
345
345
  self._thresholds_per_group: dict[str, List[float|None]] = {}
346
- self.setWindowTitle("Frame Metrics")
346
+ self.setWindowTitle(self.tr("Frame Metrics"))
347
347
  self.resize(800, 600)
348
348
 
349
349
  vbox = QVBoxLayout(self)
350
350
 
351
351
  # ← **new** instructions label
352
- instr = QLabel(
352
+ instr = QLabel(self.tr(
353
353
  "Instructions:\n"
354
354
  " • Use the filter dropdown to restrict by FILTER.\n"
355
355
  " • Click a dot to flag/unflag a frame.\n"
356
356
  " • Shift-click a dot to preview the image.\n"
357
- " • Drag the red lines to set thresholds.",
357
+ " • Drag the red lines to set thresholds."
358
+ ),
358
359
  self
359
360
  )
360
361
  instr.setWordWrap(True)
@@ -363,7 +364,7 @@ class MetricsWindow(QWidget):
363
364
 
364
365
  # → filter selector
365
366
  self.group_combo = QComboBox(self)
366
- self.group_combo.addItem("All")
367
+ self.group_combo.addItem(self.tr("All"))
367
368
  self.group_combo.currentTextChanged.connect(self._on_group_change)
368
369
  vbox.addWidget(self.group_combo)
369
370
 
@@ -407,7 +408,7 @@ class MetricsWindow(QWidget):
407
408
  continue
408
409
 
409
410
  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
+ self.status_label.setText(self.tr("Flagged Items {0}/{1} ({2:.1f}%)").format(flagged_cnt, total, pct))
411
412
 
412
413
 
413
414
  def set_images(self, loaded_images, order=None):
@@ -417,7 +418,7 @@ class MetricsWindow(QWidget):
417
418
  # ─── rebuild the combo-list of FILTER groups ─────────────
418
419
  self.group_combo.blockSignals(True)
419
420
  self.group_combo.clear()
420
- self.group_combo.addItem("All")
421
+ self.group_combo.addItem(self.tr("All"))
421
422
  seen = set()
422
423
  for entry in loaded_images:
423
424
  filt = entry.get('header', {}).get('FILTER', 'Unknown')
@@ -459,7 +460,7 @@ class MetricsWindow(QWidget):
459
460
  cur = self.group_combo.currentText()
460
461
  self.group_combo.blockSignals(True)
461
462
  self.group_combo.clear()
462
- self.group_combo.addItem("All")
463
+ self.group_combo.addItem(self.tr("All"))
463
464
  seen = set()
464
465
  for entry in self._all_images:
465
466
  filt = (entry.get('header', {}) or {}).get('FILTER', 'Unknown')
@@ -478,32 +479,82 @@ class MetricsWindow(QWidget):
478
479
  """
479
480
  Called when some frames were deleted/moved out of the list.
480
481
  Does NOT recompute metrics. Just trims cached arrays and re-plots.
482
+
483
+ Robust against:
484
+ - removed indices referring to the old list (out of range)
485
+ - metrics_panel arrays being a different length than _all_images
486
+ - stale _order_all / _current_indices containing out-of-bounds indices
481
487
  """
482
488
  if not removed:
483
489
  return
484
- removed = sorted(set(int(i) for i in removed))
485
490
 
486
- # 1) shrink cached arrays in the panel
487
- self.metrics_panel.remove_frames(removed)
491
+ # Unique + int
492
+ removed = sorted({int(i) for i in removed})
493
+
494
+ # ---- 1) Trim metrics panel caches SAFELY ----
495
+ # Prefer panel's current frame count, because it represents the arrays we must slice.
496
+ n_panel = getattr(self.metrics_panel, "n_frames", None)
497
+ if callable(n_panel):
498
+ n_panel = n_panel()
499
+ if not isinstance(n_panel, int) or n_panel <= 0:
500
+ # fallback: infer from metrics_data if present
501
+ md = getattr(self.metrics_panel, "metrics_data", None)
502
+ if md is not None and len(md) and md[0] is not None:
503
+ try:
504
+ n_panel = int(len(md[0]))
505
+ except Exception:
506
+ n_panel = 0
507
+ else:
508
+ n_panel = 0
509
+
510
+ if n_panel > 0:
511
+ removed_panel = [i for i in removed if 0 <= i < n_panel]
512
+ if removed_panel:
513
+ self.metrics_panel.remove_frames(removed_panel)
514
+ # else: panel has nothing (or isn't initialized) — just continue with ordering cleanup
488
515
 
489
- # 2) update our “master” list and ordering (object identity unchanged)
490
- # (BlinkTab will already have mutated the underlying list for us)
516
+ # ---- 2) Update ordering arrays with the SAME removed set (but clamp later) ----
491
517
  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)
518
+ if self._current_indices is not None:
519
+ self._current_indices = self._reindex_list_after_remove(self._current_indices, removed)
493
520
 
494
- # 3) rebuild group list (filters may have disappeared)
521
+ # ---- 3) Rebuild groups (filters may have disappeared) ----
495
522
  self._rebuild_groups_from_images()
496
523
 
497
- # 4) replot current group with updated order
524
+ # ---- 4) Plot with VALID indices only ----
525
+ n_imgs = len(self._all_images) if self._all_images is not None else 0
526
+
527
+ def _sanitize_indices(ixs):
528
+ if not ixs:
529
+ return []
530
+ out = []
531
+ seen = set()
532
+ for i in ixs:
533
+ try:
534
+ ii = int(i)
535
+ except Exception:
536
+ continue
537
+ if 0 <= ii < n_imgs and ii not in seen:
538
+ seen.add(ii)
539
+ out.append(ii)
540
+ return out
541
+
498
542
  indices = self._current_indices if self._current_indices is not None else self._order_all
543
+ indices = _sanitize_indices(indices)
544
+
545
+ # If the current group became empty, fall back to "all"
546
+ if not indices and n_imgs:
547
+ indices = list(range(n_imgs))
548
+ self._current_indices = indices # optional: keeps UI consistent
549
+
499
550
  self.metrics_panel.plot(self._all_images, indices=indices)
500
551
 
501
- # 5) recolor & status
552
+ # ---- 5) Recolor & status ----
502
553
  self.metrics_panel.refresh_colors_and_status()
503
554
  self._update_status()
504
555
 
505
556
  def _on_group_change(self, name: str):
506
- if name == "All":
557
+ if name == self.tr("All"):
507
558
  self._current_indices = self._order_all
508
559
  else:
509
560
  # preserve Tree order inside the chosen FILTER
@@ -538,7 +589,7 @@ class MetricsWindow(QWidget):
538
589
  else:
539
590
  if order is not None:
540
591
  self._order_all = list(order)
541
- # re-plot the current group with the new ordering
592
+ # re-plot the current group with the new ordering
542
593
  self._on_group_change(self.group_combo.currentText())
543
594
 
544
595
  class BlinkComparatorPro(QDialog):
@@ -547,7 +598,7 @@ class BlinkComparatorPro(QDialog):
547
598
  def __init__(self, doc_manager=None, parent=None):
548
599
  super().__init__(parent)
549
600
  self.doc_manager = doc_manager
550
- self.setWindowTitle("Blink Comparator")
601
+ self.setWindowTitle(self.tr("Blink Comparator"))
551
602
  self.resize(1200, 700)
552
603
 
553
604
  self.tab = BlinkTab(doc_manager=self.doc_manager, parent=self)
@@ -603,7 +654,7 @@ class BlinkTab(QWidget):
603
654
  # --------------------
604
655
  # Instruction Label
605
656
  # --------------------
606
- instruction_text = "Press 'F' to flag/unflag an image.\nRight-click on an image for more options."
657
+ instruction_text = self.tr("Press 'F' to flag/unflag an image.\nRight-click on an image for more options.")
607
658
  self.instruction_label = QLabel(instruction_text, self)
608
659
  self.instruction_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
609
660
  self.instruction_label.setWordWrap(True)
@@ -622,33 +673,33 @@ class BlinkTab(QWidget):
622
673
  button_layout = QHBoxLayout()
623
674
 
624
675
  # "Select Images" Button
625
- self.fileButton = QPushButton('Select Images', self)
676
+ self.fileButton = QPushButton(self.tr('Select Images'), self)
626
677
  self.fileButton.clicked.connect(self.openFileDialog)
627
678
  button_layout.addWidget(self.fileButton)
628
679
 
629
680
  # "Select Directory" Button
630
- self.dirButton = QPushButton('Select Directory', self)
681
+ self.dirButton = QPushButton(self.tr('Select Directory'), self)
631
682
  self.dirButton.clicked.connect(self.openDirectoryDialog)
632
683
  button_layout.addWidget(self.dirButton)
633
684
 
634
- self.addButton = QPushButton("Add Additional", self)
685
+ self.addButton = QPushButton(self.tr("Add Additional"), self)
635
686
  self.addButton.clicked.connect(self.addAdditionalImages)
636
687
  button_layout.addWidget(self.addButton)
637
688
 
638
689
  left_layout.addLayout(button_layout)
639
690
 
640
- self.metrics_button = QPushButton("Show Metrics", self)
691
+ self.metrics_button = QPushButton(self.tr("Show Metrics"), self)
641
692
  self.metrics_button.clicked.connect(self.show_metrics)
642
693
  left_layout.addWidget(self.metrics_button)
643
694
 
644
695
  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")
696
+ self.send_lights_btn = QPushButton(self.tr("→ Stacking: Lights"), self)
697
+ self.send_lights_btn.setToolTip(self.tr("Send selected (or all) blink files to the Stacking Suite → Light tab"))
647
698
  self.send_lights_btn.clicked.connect(self._send_to_stacking_lights)
648
699
  push_row.addWidget(self.send_lights_btn)
649
700
 
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")
701
+ self.send_integ_btn = QPushButton(self.tr("→ Stacking: Integration"), self)
702
+ self.send_integ_btn.setToolTip(self.tr("Send selected (or all) blink files to the Stacking Suite → Image Integration tab"))
652
703
  self.send_integ_btn.clicked.connect(self._send_to_stacking_integration)
653
704
  push_row.addWidget(self.send_integ_btn)
654
705
 
@@ -687,7 +738,7 @@ class BlinkTab(QWidget):
687
738
  # ----- Playback speed controls (0.1–10.0 fps) -----
688
739
  speed_layout = QHBoxLayout()
689
740
 
690
- speed_label = QLabel("Speed:", self)
741
+ speed_label = QLabel(self.tr("Speed:"), self)
691
742
  speed_layout.addWidget(speed_label)
692
743
 
693
744
  # Slider maps 1..100 -> 0.1..10.0 fps
@@ -695,7 +746,7 @@ class BlinkTab(QWidget):
695
746
  self.speed_slider.setRange(1, 100)
696
747
  self.speed_slider.setValue(int(round(self.play_fps * 10))) # play_fps is float
697
748
  self.speed_slider.setTickPosition(QSlider.TickPosition.NoTicks)
698
- self.speed_slider.setToolTip("Playback speed (0.1–10.0 fps)")
749
+ self.speed_slider.setToolTip(self.tr("Playback speed (0.1–10.0 fps)"))
699
750
  speed_layout.addWidget(self.speed_slider, 1)
700
751
 
701
752
  # Custom float spin (your class)
@@ -717,7 +768,7 @@ class BlinkTab(QWidget):
717
768
 
718
769
  left_layout.addLayout(speed_layout)
719
770
 
720
- self.export_button = QPushButton("Export Video…", self)
771
+ self.export_button = QPushButton(self.tr("Export Video…"), self)
721
772
  self.export_button.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_DialogSaveButton))
722
773
  self.export_button.clicked.connect(self.export_blink_video)
723
774
  left_layout.addWidget(self.export_button)
@@ -725,7 +776,7 @@ class BlinkTab(QWidget):
725
776
  # Tree view for file names
726
777
  self.fileTree = QTreeWidget(self)
727
778
  self.fileTree.setColumnCount(1)
728
- self.fileTree.setHeaderLabels(["Image Files"])
779
+ self.fileTree.setHeaderLabels([self.tr("Image Files")])
729
780
  self.fileTree.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection) # Allow multiple selections
730
781
  #self.fileTree.itemClicked.connect(self.on_item_clicked)
731
782
  self.fileTree.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
@@ -740,12 +791,12 @@ class BlinkTab(QWidget):
740
791
  left_layout.addWidget(self.fileTree)
741
792
 
742
793
  # "Clear Flags" Button
743
- self.clearFlagsButton = QPushButton('Clear Flags', self)
794
+ self.clearFlagsButton = QPushButton(self.tr('Clear Flags'), self)
744
795
  self.clearFlagsButton.clicked.connect(self.clearFlags)
745
796
  left_layout.addWidget(self.clearFlagsButton)
746
797
 
747
798
  # "Clear Images" Button
748
- self.clearButton = QPushButton('Clear Images', self)
799
+ self.clearButton = QPushButton(self.tr('Clear Images'), self)
749
800
  self.clearButton.clicked.connect(self.clearImages)
750
801
  left_layout.addWidget(self.clearButton)
751
802
 
@@ -755,7 +806,7 @@ class BlinkTab(QWidget):
755
806
  left_layout.addWidget(self.progress_bar)
756
807
 
757
808
  # Add loading message label
758
- self.loading_label = QLabel("Loading images...", self)
809
+ self.loading_label = QLabel(self.tr("Loading images..."), self)
759
810
  left_layout.addWidget(self.loading_label)
760
811
  self.imagesChanged.emit(len(self.loaded_images))
761
812
 
@@ -772,9 +823,9 @@ class BlinkTab(QWidget):
772
823
  # Zoom / preview toolbar (standardized)
773
824
  zoom_controls_layout = QHBoxLayout()
774
825
 
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")
826
+ self.zoom_in_btn = themed_toolbtn("zoom-in", self.tr("Zoom In"))
827
+ self.zoom_out_btn = themed_toolbtn("zoom-out", self.tr("Zoom Out"))
828
+ self.fit_btn = themed_toolbtn("zoom-fit-best", self.tr("Fit to Preview"))
778
829
 
779
830
  self.zoom_in_btn.clicked.connect(self.zoom_in)
780
831
  self.zoom_out_btn.clicked.connect(self.zoom_out)
@@ -787,7 +838,7 @@ class BlinkTab(QWidget):
787
838
  zoom_controls_layout.addStretch(1)
788
839
 
789
840
  # Keep Aggressive Stretch as a text toggle (it’s not really a zoom action)
790
- self.aggressive_button = QPushButton("Aggressive Stretch", self)
841
+ self.aggressive_button = QPushButton(self.tr("Aggressive Stretch"), self)
791
842
  self.aggressive_button.setCheckable(True)
792
843
  self.aggressive_button.clicked.connect(self.toggle_aggressive)
793
844
  zoom_controls_layout.addWidget(self.aggressive_button)
@@ -928,14 +979,14 @@ class BlinkTab(QWidget):
928
979
  def _send_to_stacking_lights(self):
929
980
  paths = self._collect_paths_for_stacking()
930
981
  if not paths:
931
- QMessageBox.information(self, "No images", "There are no images to send.")
982
+ QMessageBox.information(self, self.tr("No images"), self.tr("There are no images to send."))
932
983
  return
933
984
  self.sendToStacking.emit(paths, "lights")
934
985
 
935
986
  def _send_to_stacking_integration(self):
936
987
  paths = self._collect_paths_for_stacking()
937
988
  if not paths:
938
- QMessageBox.information(self, "No images", "There are no images to send.")
989
+ QMessageBox.information(self, self.tr("No images"), self.tr("There are no images to send."))
939
990
  return
940
991
  self.sendToStacking.emit(paths, "integration")
941
992
 
@@ -945,7 +996,7 @@ class BlinkTab(QWidget):
945
996
  # Ensure we have frames
946
997
  leaves = self.get_all_leaf_items()
947
998
  if not leaves:
948
- QMessageBox.information(self, "No Images", "Load images before exporting.")
999
+ QMessageBox.information(self, self.tr("No Images"), self.tr("Load images before exporting."))
949
1000
  return
950
1001
 
951
1002
  # Ask options first (size, fps, selection scope)
@@ -960,7 +1011,7 @@ class BlinkTab(QWidget):
960
1011
  if only_selected:
961
1012
  sel_leaves = [it for it in self.fileTree.selectedItems() if it.childCount() == 0]
962
1013
  if not sel_leaves:
963
- QMessageBox.information(self, "No Selection", "No individual frames selected.")
1014
+ QMessageBox.information(self, self.tr("No Selection"), self.tr("No individual frames selected."))
964
1015
  return
965
1016
  names = {it.text(0).lstrip("⚠️ ").strip() for it in sel_leaves}
966
1017
  order = [i for i in self._tree_order_indices()
@@ -969,13 +1020,13 @@ class BlinkTab(QWidget):
969
1020
  order = self._tree_order_indices()
970
1021
 
971
1022
  if not order:
972
- QMessageBox.information(self, "No Frames", "Nothing to export.")
1023
+ QMessageBox.information(self, self.tr("No Frames"), self.tr("Nothing to export."))
973
1024
  return
974
1025
 
975
1026
  if len(order) < 2:
976
1027
  ret = QMessageBox.question(
977
- self, "Only one frame",
978
- "You're about to export a video with a single frame. Continue?",
1028
+ self, self.tr("Only one frame"),
1029
+ self.tr("You're about to export a video with a single frame. Continue?"),
979
1030
  QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
980
1031
  QMessageBox.StandardButton.No,
981
1032
  )
@@ -984,22 +1035,22 @@ class BlinkTab(QWidget):
984
1035
 
985
1036
  # Ask where to save
986
1037
  out_path, _ = QFileDialog.getSaveFileName(
987
- self, "Export Blink Video", "blink.mp4", "Video (*.mp4 *.avi)"
1038
+ self, self.tr("Export Blink Video"), "blink.mp4", self.tr("Video (*.mp4 *.avi)")
988
1039
  )
989
1040
  if not out_path:
990
1041
  return
991
1042
  # Let _open_video_writer_portable decide the real extension; we pass requested
992
1043
  writer, out_path, backend = self._open_video_writer_portable(out_path, (target_w, target_h), fps)
993
1044
  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."
1045
+ QMessageBox.critical(self, self.tr("Export"),
1046
+ self.tr("No compatible video codec found.\n\n"
1047
+ "Tip: install FFmpeg or `pip install imageio[ffmpeg]` for a portable fallback.")
997
1048
  )
998
1049
  return
999
1050
 
1000
1051
  # Progress UI
1001
- prog = QProgressDialog("Rendering video…", "Cancel", 0, len(order), self)
1002
- prog.setWindowTitle("Export Blink Video")
1052
+ prog = QProgressDialog(self.tr("Rendering video…"), self.tr("Cancel"), 0, len(order), self)
1053
+ prog.setWindowTitle(self.tr("Export Blink Video"))
1003
1054
  prog.setAutoClose(True)
1004
1055
  prog.setMinimumDuration(300)
1005
1056
 
@@ -1047,25 +1098,25 @@ class BlinkTab(QWidget):
1047
1098
  os.remove(out_path)
1048
1099
  except Exception:
1049
1100
  pass
1050
- QMessageBox.information(self, "Export", "Export canceled.")
1101
+ QMessageBox.information(self, self.tr("Export"), self.tr("Export canceled."))
1051
1102
  return
1052
1103
 
1053
1104
  if frames_written == 0:
1054
- QMessageBox.critical(self, "Export", "No frames were written (codec/back-end issue?).")
1105
+ QMessageBox.critical(self, self.tr("Export"), self.tr("No frames were written (codec/back-end issue?)."))
1055
1106
  return
1056
1107
 
1057
- QMessageBox.information(self, "Export", f"Saved: {out_path}\nFrames: {frames_written} @ {fps} fps")
1108
+ QMessageBox.information(self, self.tr("Export"), self.tr("Saved: {0}\nFrames: {1} @ {2} fps").format(out_path, frames_written, fps))
1058
1109
 
1059
1110
 
1060
1111
 
1061
1112
  def _ask_video_options(self, default_fps: float):
1062
1113
  """Options dialog for size, fps, and whether to limit to current selection."""
1063
1114
  dlg = QDialog(self)
1064
- dlg.setWindowTitle("Video Options")
1115
+ dlg.setWindowTitle(self.tr("Video Options"))
1065
1116
  layout = QGridLayout(dlg)
1066
1117
 
1067
1118
  # Size
1068
- layout.addWidget(QLabel("Size:"), 0, 0)
1119
+ layout.addWidget(QLabel(self.tr("Size:")), 0, 0)
1069
1120
  size_combo = QComboBox(dlg)
1070
1121
  size_combo.addItem("HD 1280×720", (1280, 720))
1071
1122
  size_combo.addItem("Full HD 1920×1080", (1920, 1080))
@@ -1074,7 +1125,7 @@ class BlinkTab(QWidget):
1074
1125
  layout.addWidget(size_combo, 0, 1)
1075
1126
 
1076
1127
  # FPS
1077
- layout.addWidget(QLabel("FPS:"), 1, 0)
1128
+ layout.addWidget(QLabel(self.tr("FPS:")), 1, 0)
1078
1129
  fps_edit = QDoubleSpinBox(dlg)
1079
1130
  fps_edit.setRange(0.1, 60.0)
1080
1131
  fps_edit.setDecimals(2)
@@ -1083,13 +1134,13 @@ class BlinkTab(QWidget):
1083
1134
  layout.addWidget(fps_edit, 1, 1)
1084
1135
 
1085
1136
  # Only selected?
1086
- only_selected = QCheckBox("Export only selected frames", dlg)
1137
+ only_selected = QCheckBox(self.tr("Export only selected frames"), dlg)
1087
1138
  only_selected.setChecked(False) # default: export everything in tree order
1088
1139
  layout.addWidget(only_selected, 2, 0, 1, 2)
1089
1140
 
1090
1141
  # Buttons
1091
1142
  btns = QHBoxLayout()
1092
- ok = QPushButton("OK", dlg); cancel = QPushButton("Cancel", dlg)
1143
+ ok = QPushButton(self.tr("OK"), dlg); cancel = QPushButton(self.tr("Cancel"), dlg)
1093
1144
  ok.clicked.connect(dlg.accept); cancel.clicked.connect(dlg.reject)
1094
1145
  btns.addWidget(ok); btns.addWidget(cancel)
1095
1146
  layout.addLayout(btns, 3, 0, 1, 2)
@@ -1110,12 +1161,13 @@ class BlinkTab(QWidget):
1110
1161
 
1111
1162
  if not use_aggr:
1112
1163
  if stored.dtype == np.uint8:
1113
- disp8 = stored
1164
+ return stored
1114
1165
  elif stored.dtype == np.uint16:
1115
- disp8 = (stored >> 8).astype(np.uint8)
1166
+ return (stored >> 8).astype(np.uint8)
1116
1167
  else:
1117
- disp8 = (np.clip(stored, 0.0, 1.0) * 255.0).astype(np.uint8)
1118
- return disp8
1168
+ # display-only normalization for float / weird ranges
1169
+ f01 = self._ensure_float01(stored)
1170
+ return (f01 * 255.0).astype(np.uint8)
1119
1171
 
1120
1172
  base01 = self._as_float01(stored)
1121
1173
 
@@ -1206,14 +1258,32 @@ class BlinkTab(QWidget):
1206
1258
 
1207
1259
  def _update_loaded_count_label(self, n: int):
1208
1260
  # pluralize nicely
1209
- self.loading_label.setText(f"Loaded {n} image{'s' if n != 1 else ''}.")
1261
+ self.loading_label.setText(self.tr("Loaded {0} image{1}.").format(n, 's' if n != 1 else ''))
1210
1262
 
1211
1263
  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))
1264
+ # read from custom spin if present (support both .value() and .value attribute)
1265
+ fps = float(getattr(self, "play_fps", 1.0))
1266
+
1267
+ if hasattr(self, "speed_spin") and self.speed_spin is not None:
1268
+ try:
1269
+ v = getattr(self.speed_spin, "value", None)
1270
+ if callable(v):
1271
+ fps = float(v()) # QDoubleSpinBox-style
1272
+ elif v is not None:
1273
+ fps = float(v) # CustomDoubleSpinBox stores numeric attribute
1274
+ else:
1275
+ # last-resort: try Qt API name
1276
+ fps = float(self.speed_spin.value())
1277
+ except Exception:
1278
+ # fall back to existing play_fps
1279
+ pass
1280
+
1214
1281
  fps = max(0.1, min(10.0, fps))
1215
1282
  self.play_fps = fps
1216
- self.playback_timer.setInterval(int(round(1000.0 / fps))) # 0.1 fps -> 10000 ms
1283
+
1284
+ if hasattr(self, "playback_timer") and self.playback_timer is not None:
1285
+ self.playback_timer.setInterval(int(round(1000.0 / fps))) # 0.1 fps -> 10000 ms
1286
+
1217
1287
 
1218
1288
  def _on_current_item_changed_safe(self, current, previous):
1219
1289
  if not current:
@@ -1238,12 +1308,41 @@ class BlinkTab(QWidget):
1238
1308
  if item is not None:
1239
1309
  self.fileTree.scrollToItem(item, QAbstractItemView.ScrollHint.EnsureVisible)
1240
1310
 
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)
1311
+ def _leaf_path(self, item: QTreeWidgetItem) -> str | None:
1312
+ """Return full path for a leaf item, preferring UserRole; fallback to basename match."""
1313
+ if not item or item.childCount() > 0:
1314
+ return None
1315
+
1316
+ p = item.data(0, Qt.ItemDataRole.UserRole)
1317
+ if p and isinstance(p, str):
1318
+ return p
1319
+
1320
+ # fallback: basename match (legacy items)
1321
+ name = item.text(0).lstrip("⚠️ ").strip()
1322
+ if not name:
1323
+ return None
1324
+ return next((x for x in self.image_paths if os.path.basename(x) == name), None)
1325
+
1326
+
1327
+ def _leaf_index(self, item: QTreeWidgetItem) -> int | None:
1328
+ """Return index into image_paths/loaded_images for a leaf item."""
1329
+ p = self._leaf_path(item)
1330
+ if not p:
1331
+ return None
1332
+ try:
1333
+ return self.image_paths.index(p)
1334
+ except ValueError:
1335
+ return None
1336
+
1337
+
1338
+ def _set_leaf_display(self, item: QTreeWidgetItem, *, base_name: str, flagged: bool, full_path: str):
1339
+ """Update a leaf item's text + UserRole consistently."""
1340
+ disp = base_name
1341
+ if flagged:
1342
+ disp = f"⚠️ {disp}"
1343
+ item.setText(0, disp)
1344
+ item.setData(0, Qt.ItemDataRole.UserRole, full_path)
1345
+
1247
1346
 
1248
1347
  def clearFlags(self):
1249
1348
  """Clear all flagged states, update tree icons & metrics."""
@@ -1280,14 +1379,14 @@ class BlinkTab(QWidget):
1280
1379
  """Let the user pick more images to append to the blink list."""
1281
1380
  file_paths, _ = QFileDialog.getOpenFileNames(
1282
1381
  self,
1283
- "Add Additional Images",
1382
+ self.tr("Add Additional Images"),
1284
1383
  "",
1285
- "Images (*.png *.tif *.tiff *.fits *.fit *.xisf *.cr2 *.nef *.arw *.dng *.raf *.orf *.rw2 *.pef);;All Files (*)"
1384
+ self.tr("Images (*.png *.tif *.tiff *.fits *.fit *.xisf *.cr2 *.nef *.arw *.dng *.raf *.orf *.rw2 *.pef);;All Files (*)")
1286
1385
  )
1287
1386
  # filter out duplicates
1288
1387
  new_paths = [p for p in file_paths if p not in self.image_paths]
1289
1388
  if not new_paths:
1290
- QMessageBox.information(self, "No New Images", "No new images selected or already loaded.")
1389
+ QMessageBox.information(self, self.tr("No New Images"), self.tr("No new images selected or already loaded."))
1291
1390
  return
1292
1391
  self._appendImages(new_paths)
1293
1392
 
@@ -1335,7 +1434,7 @@ class BlinkTab(QWidget):
1335
1434
  self.add_item_to_tree(path)
1336
1435
 
1337
1436
  # update status
1338
- self.loading_label.setText(f"Loaded {len(self.loaded_images)} images.")
1437
+ self.loading_label.setText(self.tr("Loaded {0} images.").format(len(self.loaded_images)))
1339
1438
  if self.metrics_window and self.metrics_window.isVisible():
1340
1439
  self.metrics_window.update_metrics(self.loaded_images, order=self._tree_order_indices())
1341
1440
 
@@ -1351,7 +1450,7 @@ class BlinkTab(QWidget):
1351
1450
  order = self._tree_order_indices()
1352
1451
  self.metrics_window.set_images(self.loaded_images, order=order)
1353
1452
  panel = self.metrics_window.metrics_panel
1354
- self.thresholds_by_group["All"] = [line.value() for line in panel.lines]
1453
+ self.thresholds_by_group[self.tr("All")] = [line.value() for line in panel.lines]
1355
1454
  self.metrics_window.show()
1356
1455
  self.metrics_window.raise_()
1357
1456
 
@@ -1405,7 +1504,7 @@ class BlinkTab(QWidget):
1405
1504
  thr_list[metric_idx] = threshold
1406
1505
 
1407
1506
  # build the list of indices to re-evaluate
1408
- if group == "All":
1507
+ if group == self.tr("All"):
1409
1508
  indices = range(len(self.loaded_images))
1410
1509
  else:
1411
1510
  indices = [
@@ -1450,6 +1549,7 @@ class BlinkTab(QWidget):
1450
1549
  """Rebuild the left tree from self.loaded_images without reloading or recomputing."""
1451
1550
  self.fileTree.clear()
1452
1551
  from collections import defaultdict
1552
+
1453
1553
  grouped = defaultdict(list)
1454
1554
  for entry in self.loaded_images:
1455
1555
  hdr = entry.get('header', {}) or {}
@@ -1467,25 +1567,29 @@ class BlinkTab(QWidget):
1467
1567
  by_object[obj][fil][exp] = paths
1468
1568
 
1469
1569
  for obj in sorted(by_object, key=lambda o: o.lower()):
1470
- obj_item = QTreeWidgetItem([f"Object: {obj}"])
1570
+ obj_item = QTreeWidgetItem([self.tr("Object: {0}").format(obj)])
1471
1571
  self.fileTree.addTopLevelItem(obj_item)
1472
1572
  obj_item.setExpanded(True)
1573
+
1473
1574
  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)
1575
+ filt_item = QTreeWidgetItem([self.tr("Filter: {0}").format(fil)])
1576
+ obj_item.addChild(filt_item)
1577
+ filt_item.setExpanded(True)
1578
+
1477
1579
  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)
1580
+ exp_item = QTreeWidgetItem([self.tr("Exposure: {0}").format(exp)])
1581
+ filt_item.addChild(exp_item)
1480
1582
  exp_item.setExpanded(True)
1583
+
1481
1584
  for p in by_object[obj][fil][exp]:
1482
1585
  leaf = QTreeWidgetItem([os.path.basename(p)])
1483
1586
  leaf.setData(0, Qt.ItemDataRole.UserRole, p)
1484
1587
  exp_item.addChild(leaf)
1485
1588
 
1486
- # 🔹 NEW: re-apply flagged styling
1589
+ # 🔹 Re-apply flagged styling
1487
1590
  RED = Qt.GlobalColor.red
1488
1591
  normal = self.fileTree.palette().color(QPalette.ColorRole.WindowText)
1592
+
1489
1593
  for idx, entry in enumerate(self.loaded_images):
1490
1594
  item = self.get_tree_item_for_index(idx)
1491
1595
  if not item:
@@ -1499,32 +1603,25 @@ class BlinkTab(QWidget):
1499
1603
  item.setForeground(0, QBrush(normal))
1500
1604
 
1501
1605
 
1606
+
1502
1607
  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
1608
  self._rebuild_tree_from_loaded()
1506
1609
  self.imagesChanged.emit(len(self.loaded_images))
1507
1610
 
1508
- # 2) refresh metrics (if open) WITHOUT recomputing SEP
1509
1611
  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
- )
1612
+ # ✅ safest: rebind images + rebuild plot order from tree
1613
+ self.metrics_window.set_images(self.loaded_images, order=self._tree_order_indices())
1614
+ self._sync_metrics_flags()
1520
1615
 
1521
1616
  def get_tree_item_for_index(self, idx):
1522
- target = os.path.basename(self.image_paths[idx])
1617
+ target_path = self.image_paths[idx]
1523
1618
  for item in self.get_all_leaf_items():
1524
- if item.text(0).lstrip("⚠️ ") == target:
1619
+ p = item.data(0, Qt.ItemDataRole.UserRole)
1620
+ if p == target_path:
1525
1621
  return item
1526
1622
  return None
1527
1623
 
1624
+
1528
1625
  def compute_metric(self, metric_idx, entry):
1529
1626
  """Recompute a single metric for one image. Use cached orig_background for metric 2."""
1530
1627
  # metric 2 is the pre-stretch background we already computed
@@ -1573,7 +1670,7 @@ class BlinkTab(QWidget):
1573
1670
 
1574
1671
  def openDirectoryDialog(self):
1575
1672
  """Allow users to select a directory and load all images within it recursively."""
1576
- directory = QFileDialog.getExistingDirectory(self, "Select Directory", "")
1673
+ directory = QFileDialog.getExistingDirectory(self, self.tr("Select Directory"), "")
1577
1674
  if directory:
1578
1675
  # Supported image extensions
1579
1676
  supported_extensions = (
@@ -1594,15 +1691,15 @@ class BlinkTab(QWidget):
1594
1691
  if new_file_paths:
1595
1692
  self.loadImages(new_file_paths)
1596
1693
  else:
1597
- QMessageBox.information(self, "No Images Found", "No supported image files were found in the selected directory.")
1694
+ QMessageBox.information(self, self.tr("No Images Found"), self.tr("No supported image files were found in the selected directory."))
1598
1695
 
1599
1696
 
1600
1697
  def clearImages(self):
1601
1698
  """Clear all loaded images and reset the tree view."""
1602
1699
  confirmation = QMessageBox.question(
1603
1700
  self,
1604
- "Clear All Images",
1605
- "Are you sure you want to clear all loaded images?",
1701
+ self.tr("Clear All Images"),
1702
+ self.tr("Are you sure you want to clear all loaded images?"),
1606
1703
  QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
1607
1704
  QMessageBox.StandardButton.No
1608
1705
  )
@@ -1613,10 +1710,10 @@ class BlinkTab(QWidget):
1613
1710
  self.image_labels.clear()
1614
1711
  self.fileTree.clear()
1615
1712
  self.preview_label.clear()
1616
- self.preview_label.setText('No image selected.')
1713
+ self.preview_label.setText(self.tr('No image selected.'))
1617
1714
  self.current_pixmap = None
1618
1715
  self.progress_bar.setValue(0)
1619
- self.loading_label.setText("Loading images...")
1716
+ self.loading_label.setText(self.tr("Loading images..."))
1620
1717
  self.imagesChanged.emit(len(self.loaded_images))
1621
1718
 
1622
1719
  # (legacy) if you still have this, you can delete it:
@@ -1649,31 +1746,27 @@ class BlinkTab(QWidget):
1649
1746
  # 1) load
1650
1747
  image, header, bit_depth, is_mono = load_image(file_path)
1651
1748
  if image is None or image.size == 0:
1652
- raise ValueError("Empty image")
1749
+ msg = QCoreApplication.translate("BlinkTab", "Empty image")
1750
+ raise ValueError(msg)
1653
1751
 
1654
1752
  # 2) optional debayer
1655
1753
  if is_mono:
1656
- # adjust this call to match your debayer signature
1657
1754
  image = BlinkTab.debayer_image(image, file_path, header)
1658
1755
 
1659
- # ✅ NEW: force 0..1 range BEFORE SEP + stretch
1660
1756
  image = BlinkTab._ensure_float01(image)
1661
1757
 
1662
- # 3) SEP background on mono float32
1663
1758
  data = np.asarray(image, dtype=np.float32, order='C')
1664
1759
  if data.ndim == 3:
1665
1760
  data = data.mean(axis=2)
1666
1761
  bkg = sep.Background(data)
1667
1762
  global_back = bkg.globalback
1668
1763
 
1669
- # 4) stretch
1670
1764
  target_med = 0.25
1671
1765
  if image.ndim == 2:
1672
1766
  stretched = stretch_mono_image(image, target_med)
1673
1767
  else:
1674
1768
  stretched = stretch_color_image(image, target_med, linked=False)
1675
1769
 
1676
- # 5) cast to target_dtype
1677
1770
  clipped = np.clip(stretched, 0.0, 1.0)
1678
1771
  if target_dtype is np.uint8:
1679
1772
  stored = (clipped * 255).astype(np.uint8)
@@ -1823,7 +1916,7 @@ class BlinkTab(QWidget):
1823
1916
  leaf.setData(0, Qt.ItemDataRole.UserRole, p)
1824
1917
  exp_item.addChild(leaf)
1825
1918
 
1826
- self.loading_label.setText(f"Loaded {len(self.loaded_images)} images.")
1919
+ self.loading_label.setText(self.tr("Loaded {0} images.").format(len(self.loaded_images)))
1827
1920
  self.progress_bar.setValue(100)
1828
1921
  self.imagesChanged.emit(len(self.loaded_images))
1829
1922
  if self.metrics_window and self.metrics_window.isVisible():
@@ -1848,33 +1941,35 @@ class BlinkTab(QWidget):
1848
1941
 
1849
1942
 
1850
1943
  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:
1944
+ idx = self._leaf_index(item)
1945
+ if idx is None:
1854
1946
  return
1855
1947
 
1856
- idx = self.image_paths.index(file_path)
1857
1948
  entry = self.loaded_images[idx]
1858
- entry['flagged'] = not entry['flagged']
1949
+ entry['flagged'] = not bool(entry.get('flagged', False))
1859
1950
 
1860
1951
  RED = Qt.GlobalColor.red
1861
- palette = self.fileTree.palette()
1862
- normal_color = palette.color(QPalette.ColorRole.WindowText)
1952
+ normal_color = self.fileTree.palette().color(QPalette.ColorRole.WindowText)
1953
+
1954
+ base = os.path.basename(self.image_paths[idx])
1863
1955
 
1864
1956
  if entry['flagged']:
1865
- item.setText(0, f"⚠️ {file_name}")
1957
+ item.setText(0, f"⚠️ {base}")
1866
1958
  item.setForeground(0, QBrush(RED))
1867
1959
  else:
1868
- item.setText(0, file_name)
1960
+ item.setText(0, base)
1869
1961
  item.setForeground(0, QBrush(normal_color))
1870
1962
 
1963
+ # Keep UserRole correct (in case this was a legacy leaf)
1964
+ item.setData(0, Qt.ItemDataRole.UserRole, self.image_paths[idx])
1965
+
1871
1966
  if sync_metrics:
1872
1967
  self._sync_metrics_flags()
1873
1968
 
1874
1969
  def flag_current_image(self):
1875
1970
  item = self.fileTree.currentItem()
1876
1971
  if not item:
1877
- QMessageBox.warning(self, "No Selection", "No image is currently selected to flag.")
1972
+ QMessageBox.warning(self, self.tr("No Selection"), self.tr("No image is currently selected to flag."))
1878
1973
  return
1879
1974
  self._toggle_flag_on_item(item) # ← this now updates the metrics panel too
1880
1975
  self.next_item()
@@ -1950,7 +2045,7 @@ class BlinkTab(QWidget):
1950
2045
 
1951
2046
  leaves = self.get_all_leaf_items()
1952
2047
  if not leaves:
1953
- QMessageBox.information(self, "No Images", "Load some images first.")
2048
+ QMessageBox.information(self, self.tr("No Images"), self.tr("Load some images first."))
1954
2049
  return
1955
2050
 
1956
2051
  # Ensure a current leaf item is selected
@@ -1972,9 +2067,9 @@ class BlinkTab(QWidget):
1972
2067
  """Allow users to select multiple images and add them to the existing list."""
1973
2068
  file_paths, _ = QFileDialog.getOpenFileNames(
1974
2069
  self,
1975
- "Open Images",
2070
+ self.tr("Open Images"),
1976
2071
  "",
1977
- "Images (*.png *.tif *.tiff *.fits *.fit *.xisf *.cr2 *.cr3 *.nef *.arw *.dng *.raf *.orf *.rw2 *.pef);;All Files (*)"
2072
+ self.tr("Images (*.png *.tif *.tiff *.fits *.fit *.xisf *.cr2 *.cr3 *.nef *.arw *.dng *.raf *.orf *.rw2 *.pef);;All Files (*)")
1978
2073
  )
1979
2074
 
1980
2075
  # Filter out already loaded images to prevent duplicates
@@ -1983,7 +2078,7 @@ class BlinkTab(QWidget):
1983
2078
  if new_file_paths:
1984
2079
  self.loadImages(new_file_paths)
1985
2080
  else:
1986
- QMessageBox.information(self, "No New Images", "No new images were selected or all selected images are already loaded.")
2081
+ QMessageBox.information(self, self.tr("No New Images"), self.tr("No new images were selected or all selected images are already loaded."))
1987
2082
 
1988
2083
 
1989
2084
  def debayer_fits(self, image_data, bayer_pattern):
@@ -2033,7 +2128,7 @@ class BlinkTab(QWidget):
2033
2128
  return np.stack([r, g, b], axis=-1)
2034
2129
 
2035
2130
  else:
2036
- raise ValueError(f"Unsupported Bayer pattern: {bayer_pattern}")
2131
+ raise ValueError(self.tr("Unsupported Bayer pattern: {0}").format(bayer_pattern))
2037
2132
 
2038
2133
  def remove_item_from_tree(self, file_path):
2039
2134
  """Remove a specific item from the tree view based on file path."""
@@ -2150,59 +2245,36 @@ class BlinkTab(QWidget):
2150
2245
  g = (g1 + g2) / 2
2151
2246
  return np.stack([r, g, b], axis=-1)
2152
2247
  else:
2153
- raise ValueError(f"Unsupported Bayer pattern: {bayer_pattern}")
2248
+ raise ValueError(self.tr("Unsupported Bayer pattern: {0}").format(bayer_pattern))
2154
2249
 
2155
2250
 
2156
2251
 
2157
2252
  def on_item_clicked(self, item, column):
2158
2253
  self.fileTree.setFocus()
2254
+ if not item or item.childCount() > 0:
2255
+ return
2159
2256
 
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)
2257
+ file_path = self._leaf_path(item)
2162
2258
  if not file_path:
2163
2259
  return
2164
2260
 
2165
2261
  self._capture_view_center_norm()
2166
2262
 
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)
2263
+ try:
2264
+ idx = self.image_paths.index(file_path)
2265
+ except ValueError:
2266
+ return
2198
2267
 
2199
- disp8 = (disp01 * 255.0).astype(np.uint8)
2268
+ entry = self.loaded_images[idx]
2200
2269
 
2270
+ # ✅ single source of truth (handles aggressive + mono + color)
2271
+ disp8 = self._make_display_frame(entry)
2201
2272
 
2202
2273
  qimage = self.convert_to_qimage(disp8)
2203
2274
  self.current_pixmap = QPixmap.fromImage(qimage)
2204
2275
  self.apply_zoom()
2205
2276
 
2277
+
2206
2278
  def _capture_view_center_norm(self):
2207
2279
  """Remember the current viewport center as a fraction of the content size."""
2208
2280
  sa = self.scroll_area
@@ -2305,7 +2377,7 @@ class BlinkTab(QWidget):
2305
2377
  self.apply_zoom()
2306
2378
  else:
2307
2379
  print("No image loaded. Cannot fit to preview.")
2308
- QMessageBox.warning(self, "Warning", "No image loaded. Cannot fit to preview.")
2380
+ QMessageBox.warning(self, self.tr("Warning"), self.tr("No image loaded. Cannot fit to preview."))
2309
2381
 
2310
2382
  def _is_leaf(self, item: Optional[QTreeWidgetItem]) -> bool:
2311
2383
  return bool(item and item.childCount() == 0)
@@ -2318,108 +2390,173 @@ class BlinkTab(QWidget):
2318
2390
 
2319
2391
  menu = QMenu(self)
2320
2392
 
2321
- push_action = QAction("Open in Document Window", self)
2393
+ push_action = QAction(self.tr("Open in Document Window"), self)
2322
2394
  push_action.triggered.connect(lambda: self.push_to_docs(item))
2323
2395
  menu.addAction(push_action)
2324
2396
 
2325
- rename_action = QAction("Rename", self)
2397
+ rename_action = QAction(self.tr("Rename"), self)
2326
2398
  rename_action.triggered.connect(lambda: self.rename_item(item))
2327
2399
  menu.addAction(rename_action)
2328
2400
 
2329
2401
  # 🔹 NEW: batch rename selected
2330
- batch_rename_action = QAction("Batch Rename Selected…", self)
2402
+ batch_rename_action = QAction(self.tr("Batch Rename Selected…"), self)
2331
2403
  batch_rename_action.triggered.connect(self.batch_rename_items)
2332
2404
  menu.addAction(batch_rename_action)
2333
2405
 
2334
- move_action = QAction("Move Selected Items", self)
2406
+ move_action = QAction(self.tr("Move Selected Items"), self)
2335
2407
  move_action.triggered.connect(self.move_items)
2336
2408
  menu.addAction(move_action)
2337
2409
 
2338
- delete_action = QAction("Delete Selected Items", self)
2410
+ delete_action = QAction(self.tr("Delete Selected Items"), self)
2339
2411
  delete_action.triggered.connect(self.delete_items)
2340
2412
  menu.addAction(delete_action)
2341
2413
 
2342
2414
  menu.addSeparator()
2343
2415
 
2344
- batch_delete_action = QAction("Delete All Flagged Images", self)
2416
+ batch_delete_action = QAction(self.tr("Delete All Flagged Images"), self)
2345
2417
  batch_delete_action.triggered.connect(self.batch_delete_flagged_images)
2346
2418
  menu.addAction(batch_delete_action)
2347
2419
 
2348
- batch_move_action = QAction("Move All Flagged Images", self)
2420
+ batch_move_action = QAction(self.tr("Move All Flagged Images"), self)
2349
2421
  batch_move_action.triggered.connect(self.batch_move_flagged_images)
2350
2422
  menu.addAction(batch_move_action)
2351
2423
 
2352
2424
  # 🔹 NEW: rename all flagged images
2353
- rename_flagged_action = QAction("Rename Flagged Images…", self)
2425
+ rename_flagged_action = QAction(self.tr("Rename Flagged Images…"), self)
2354
2426
  rename_flagged_action.triggered.connect(self.rename_flagged_images)
2355
2427
  menu.addAction(rename_flagged_action)
2356
2428
 
2357
2429
  menu.addSeparator()
2358
2430
 
2359
- send_lights_act = QAction("Send to Stacking → Lights", self)
2431
+ send_lights_act = QAction(self.tr("Send to Stacking → Lights"), self)
2360
2432
  send_lights_act.triggered.connect(self._send_to_stacking_lights)
2361
2433
  menu.addAction(send_lights_act)
2362
2434
 
2363
- send_integ_act = QAction("Send to Stacking → Integration", self)
2435
+ send_integ_act = QAction(self.tr("Send to Stacking → Integration"), self)
2364
2436
  send_integ_act.triggered.connect(self._send_to_stacking_integration)
2365
2437
  menu.addAction(send_integ_act)
2366
2438
 
2367
2439
  menu.exec(self.fileTree.mapToGlobal(pos))
2368
2440
 
2369
2441
 
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)
2442
+ def push_to_docs(self, item: QTreeWidgetItem):
2443
+ """
2444
+ Push the currently selected blink leaf image into DocManager as a new document,
2445
+ preserving all original metadata (original_header, meta, bit_depth, is_mono, etc.)
2446
+ and swapping ONLY the numpy image array.
2447
+ """
2448
+ if not item or item.childCount() > 0:
2449
+ return
2450
+
2451
+ # --- Resolve full path safely (UserRole-first) ---
2452
+ file_path = item.data(0, Qt.ItemDataRole.UserRole)
2453
+ if not file_path or not isinstance(file_path, str):
2454
+ # legacy fallback: try to map by displayed name
2455
+ file_name = item.text(0).lstrip("⚠️ ").strip()
2456
+ file_path = next((p for p in self.image_paths if os.path.basename(p) == file_name), None)
2457
+
2374
2458
  if not file_path:
2375
2459
  return
2376
- idx = self.image_paths.index(file_path)
2460
+
2461
+ try:
2462
+ idx = self.image_paths.index(file_path)
2463
+ except ValueError:
2464
+ return
2465
+
2377
2466
  entry = self.loaded_images[idx]
2378
2467
 
2379
- # Find main window + doc manager
2468
+ # --- Find main window + doc manager ---
2380
2469
  mw = self._main_window()
2381
2470
  dm = self.doc_manager or (getattr(mw, "docman", None) if mw else None)
2382
2471
  if not mw or not dm:
2383
- QMessageBox.warning(self, "Document Manager", "Main window or DocManager not available.")
2472
+ QMessageBox.warning(self, self.tr("Document Manager"), self.tr("Main window or DocManager not available."))
2384
2473
  return
2385
2474
 
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',
2475
+ # --- Build the swapped payload (image replaced, metadata preserved) ---
2476
+ # Whatever you're storing as entry['image_data'] (uint16/float/etc), normalize to float01 for display pipeline.
2477
+ # If your DocManager expects native dtype instead, swap _as_float01 for your native image.
2478
+ np_image_f01 = self._as_float01(entry["image_data"]).astype(np.float32, copy=False)
2479
+
2480
+ # Preserve your full load_image return structure as much as possible:
2481
+ # load_image returns: image, original_header, bit_depth, is_mono, meta
2482
+ original_header = entry.get("original_header", entry.get("header", None))
2483
+ bit_depth = entry.get("bit_depth", None)
2484
+ is_mono = entry.get("is_mono", None)
2485
+ meta = entry.get("meta", {})
2486
+
2487
+ # Keep meta dict style your app uses; add source tag without clobbering
2488
+ if isinstance(meta, dict):
2489
+ meta = dict(meta)
2490
+ meta.setdefault("source", "BlinkComparatorPro")
2491
+ meta.setdefault("file_path", file_path)
2492
+
2493
+ # This is the "all the other stuff" you wanted preserved
2494
+ payload = {
2495
+ "file_path": file_path,
2496
+ "original_header": original_header,
2497
+ "bit_depth": bit_depth,
2498
+ "is_mono": is_mono,
2499
+ "meta": meta,
2500
+ "source": "BlinkComparatorPro",
2394
2501
  }
2502
+
2395
2503
  title = os.path.basename(file_path)
2396
2504
 
2397
- # Create the document using whatever API your DocManager has
2505
+ # --- Create document using whatever DocManager API exists ---
2398
2506
  doc = None
2399
2507
  try:
2400
- if hasattr(dm, "open_array"):
2401
- doc = dm.open_array(np_image_f01, metadata=metadata, title=title)
2508
+ # Preferred: if you have a method that mirrors open_file/load_image shape
2509
+ if hasattr(dm, "open_from_load_image"):
2510
+ # (image, original_header, bit_depth, is_mono, meta)
2511
+ doc = dm.open_from_load_image(np_image_f01, original_header, bit_depth, is_mono, meta, title=title)
2512
+
2513
+ elif hasattr(dm, "open_array"):
2514
+ # Some of your code expects metadata in doc.metadata; pass payload whole
2515
+ doc = dm.open_array(np_image_f01, metadata=payload, title=title)
2516
+
2402
2517
  elif hasattr(dm, "open_numpy"):
2403
- doc = dm.open_numpy(np_image_f01, metadata=metadata, title=title)
2518
+ doc = dm.open_numpy(np_image_f01, metadata=payload, title=title)
2519
+
2404
2520
  elif hasattr(dm, "create_document"):
2405
- doc = dm.create_document(image=np_image_f01, metadata=metadata, name=title)
2521
+ # Try both signatures
2522
+ try:
2523
+ doc = dm.create_document(image=np_image_f01, metadata=payload, name=title)
2524
+ except TypeError:
2525
+ doc = dm.create_document(np_image_f01, payload, title)
2526
+
2406
2527
  else:
2407
- raise AttributeError("DocManager lacks open_array/open_numpy/create_document")
2528
+ raise AttributeError("DocManager lacks a known creation method")
2529
+
2408
2530
  except Exception as e:
2409
- QMessageBox.critical(self, "Doc Manager", f"Failed to create document:\n{e}")
2531
+ QMessageBox.critical(self, self.tr("Doc Manager"), self.tr("Failed to create document:\n{0}").format(e))
2410
2532
  return
2411
2533
 
2412
2534
  if doc is None:
2413
- QMessageBox.critical(self, "Doc Manager", "DocManager returned no document.")
2535
+ QMessageBox.critical(self, self.tr("Doc Manager"), self.tr("DocManager returned no document."))
2414
2536
  return
2415
2537
 
2416
- # SHOW it: ask the main window to spawn an MDI subwindow
2538
+ # --- Hand off to DocManager flow (DocManager should trigger MDI + window creation) ---
2417
2539
  try:
2418
- mw._spawn_subwindow_for(doc)
2540
+ # If your architecture already auto-spawns windows on documentAdded,
2541
+ # you should NOT call mw._spawn_subwindow_for(doc) here.
2542
+ if hasattr(dm, "add_document"):
2543
+ dm.add_document(doc)
2544
+ elif hasattr(dm, "register_document"):
2545
+ dm.register_document(doc)
2546
+ else:
2547
+ # If open_array/open_numpy already registers the doc internally, do nothing.
2548
+ pass
2549
+
2550
+ # If you *must* spawn manually (older path), keep as fallback
2551
+ if hasattr(mw, "_spawn_subwindow_for"):
2552
+ mw._spawn_subwindow_for(doc)
2553
+
2419
2554
  if hasattr(mw, "_log"):
2420
2555
  mw._log(f"Blink → opened '{title}' as new document")
2556
+
2421
2557
  except Exception as e:
2422
- QMessageBox.critical(self, "UI", f"Failed to open subwindow:\n{e}")
2558
+ QMessageBox.critical(self, self.tr("UI"), self.tr("Failed to open subwindow:\n{0}").format(e))
2559
+
2423
2560
 
2424
2561
 
2425
2562
  # optional shim to keep any old calls working
@@ -2428,27 +2565,55 @@ class BlinkTab(QWidget):
2428
2565
 
2429
2566
 
2430
2567
 
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)
2568
+ def rename_item(self, item: QTreeWidgetItem):
2569
+ if not item or item.childCount() > 0:
2570
+ return
2435
2571
 
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)
2572
+ idx = self._leaf_index(item)
2573
+ if idx is None:
2574
+ return
2575
+
2576
+ old_path = self.image_paths[idx]
2577
+ old_base = os.path.basename(old_path)
2578
+
2579
+ new_name, ok = QInputDialog.getText(
2580
+ self,
2581
+ self.tr("Rename Image"),
2582
+ self.tr("Enter new name:"),
2583
+ text=old_base
2584
+ )
2585
+ if not ok:
2586
+ return
2587
+
2588
+ new_name = (new_name or "").strip()
2589
+ if not new_name:
2590
+ return
2591
+
2592
+ new_path = os.path.join(os.path.dirname(old_path), new_name)
2593
+
2594
+ # Avoid overwrite
2595
+ if os.path.exists(new_path):
2596
+ QMessageBox.critical(self, self.tr("Error"), self.tr("A file with that name already exists."))
2597
+ return
2598
+
2599
+ try:
2600
+ os.rename(old_path, new_path)
2601
+ except Exception as e:
2602
+ QMessageBox.critical(self, self.tr("Error"), self.tr("Failed to rename the file: {0}").format(e))
2603
+ return
2604
+
2605
+ # Update internal structures
2606
+ self.image_paths[idx] = new_path
2607
+ self.loaded_images[idx]['file_path'] = new_path
2608
+
2609
+ # Update the leaf item
2610
+ flagged = bool(self.loaded_images[idx].get("flagged", False))
2611
+ self._set_leaf_display(item, base_name=new_name, flagged=flagged, full_path=new_path)
2612
+
2613
+ # Rebuild so natural sort stays correct and groups update
2614
+ self._after_list_changed()
2615
+ self._sync_metrics_flags()
2441
2616
 
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
2617
 
2453
2618
  def rename_flagged_images(self):
2454
2619
  """Prefix all *flagged* images on disk and in the tree."""
@@ -2459,17 +2624,17 @@ class BlinkTab(QWidget):
2459
2624
  if not flagged_indices:
2460
2625
  QMessageBox.information(
2461
2626
  self,
2462
- "Rename Flagged Images",
2463
- "There are no flagged images to rename."
2627
+ self.tr("Rename Flagged Images"),
2628
+ self.tr("There are no flagged images to rename.")
2464
2629
  )
2465
2630
  return
2466
2631
 
2467
2632
  # Small dialog like in your mockup: just a prefix field
2468
2633
  dlg = QDialog(self)
2469
- dlg.setWindowTitle("Rename flagged images")
2634
+ dlg.setWindowTitle(self.tr("Rename flagged images"))
2470
2635
  layout = QVBoxLayout(dlg)
2471
2636
 
2472
- layout.addWidget(QLabel("Prefix to add to flagged image filenames:", dlg))
2637
+ layout.addWidget(QLabel(self.tr("Prefix to add to flagged image filenames:"), dlg))
2473
2638
 
2474
2639
  prefix_edit = QLineEdit(dlg)
2475
2640
  prefix_edit.setText("Bad_") # sensible default
@@ -2494,9 +2659,9 @@ class BlinkTab(QWidget):
2494
2659
  # Allow empty but warn – otherwise user may be confused
2495
2660
  ret = QMessageBox.question(
2496
2661
  self,
2497
- "No Prefix",
2498
- "No prefix entered. This will not change any filenames.\n\n"
2499
- "Continue anyway?",
2662
+ self.tr("No Prefix"),
2663
+ self.tr("No prefix entered. This will not change any filenames.\n\n"
2664
+ "Continue anyway?"),
2500
2665
  QMessageBox.StandardButton.Yes | QDialogButtonBox.StandardButton.No,
2501
2666
  QMessageBox.StandardButton.No,
2502
2667
  )
@@ -2549,102 +2714,125 @@ class BlinkTab(QWidget):
2549
2714
  # Also sync the metrics panel flags/colors
2550
2715
  self._sync_metrics_flags()
2551
2716
 
2552
- msg = f"Renamed {successes} flagged image{'s' if successes != 1 else ''}."
2717
+ msg = self.tr("Renamed {0} flagged image{1}.").format(successes, 's' if successes != 1 else '')
2553
2718
  if failures:
2554
- msg += f"\n\n{len(failures)} file(s) could not be renamed:"
2719
+ msg += self.tr("\n\n{0} file(s) could not be renamed:").format(len(failures))
2555
2720
  for old, err in failures[:10]: # don’t spam too hard
2556
2721
  msg += f"\n• {os.path.basename(old)} – {err}"
2557
2722
 
2558
- QMessageBox.information(self, "Rename Flagged Images", msg)
2723
+ QMessageBox.information(self, self.tr("Rename Flagged Images"), msg)
2559
2724
 
2560
2725
 
2561
2726
  def batch_rename_items(self):
2562
- """Batch rename selected items by adding a prefix or suffix."""
2563
- selected_items = self.fileTree.selectedItems()
2564
-
2727
+ """Batch rename selected leaf items by adding a prefix and/or suffix."""
2728
+ selected_items = [it for it in self.fileTree.selectedItems() if it and it.childCount() == 0]
2565
2729
  if not selected_items:
2566
- QMessageBox.warning(self, "Warning", "No items selected for renaming.")
2730
+ QMessageBox.warning(self, self.tr("Warning"), self.tr("No individual image items selected for renaming."))
2567
2731
  return
2568
2732
 
2569
- # Create a custom dialog for entering the prefix and suffix
2570
2733
  dialog = QDialog(self)
2571
- dialog.setWindowTitle("Batch Rename")
2734
+ dialog.setWindowTitle(self.tr("Batch Rename"))
2572
2735
  dialog_layout = QVBoxLayout(dialog)
2573
2736
 
2574
- instruction_label = QLabel("Enter a prefix or suffix to rename selected files:")
2575
- dialog_layout.addWidget(instruction_label)
2737
+ dialog_layout.addWidget(QLabel(self.tr("Enter a prefix or suffix to rename selected files:"), dialog))
2576
2738
 
2577
- # Create fields for prefix and suffix
2578
2739
  form_layout = QHBoxLayout()
2579
-
2580
2740
  prefix_field = QLineEdit(dialog)
2581
- prefix_field.setPlaceholderText("Prefix")
2741
+ prefix_field.setPlaceholderText(self.tr("Prefix"))
2582
2742
  form_layout.addWidget(prefix_field)
2583
2743
 
2584
- current_filename_label = QLabel("currentfilename", dialog)
2585
- form_layout.addWidget(current_filename_label)
2744
+ mid_label = QLabel(self.tr("filename"), dialog)
2745
+ mid_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
2746
+ form_layout.addWidget(mid_label)
2586
2747
 
2587
2748
  suffix_field = QLineEdit(dialog)
2588
- suffix_field.setPlaceholderText("Suffix")
2749
+ suffix_field.setPlaceholderText(self.tr("Suffix"))
2589
2750
  form_layout.addWidget(suffix_field)
2590
-
2591
2751
  dialog_layout.addLayout(form_layout)
2592
2752
 
2593
- # Add OK and Cancel buttons
2594
- button_layout = QHBoxLayout()
2595
- ok_button = QPushButton("OK", dialog)
2753
+ btns = QHBoxLayout()
2754
+ ok_button = QPushButton(self.tr("OK"), dialog)
2755
+ cancel_button = QPushButton(self.tr("Cancel"), dialog)
2596
2756
  ok_button.clicked.connect(dialog.accept)
2597
- button_layout.addWidget(ok_button)
2598
-
2599
- cancel_button = QPushButton("Cancel", dialog)
2600
2757
  cancel_button.clicked.connect(dialog.reject)
2601
- button_layout.addWidget(cancel_button)
2758
+ btns.addWidget(ok_button)
2759
+ btns.addWidget(cancel_button)
2760
+ dialog_layout.addLayout(btns)
2602
2761
 
2603
- dialog_layout.addLayout(button_layout)
2762
+ if dialog.exec() != QDialog.DialogCode.Accepted:
2763
+ return
2604
2764
 
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()
2765
+ prefix = (prefix_field.text() or "").strip()
2766
+ suffix = (suffix_field.text() or "").strip()
2609
2767
 
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)
2768
+ if not prefix and not suffix:
2769
+ QMessageBox.information(self, self.tr("Batch Rename"), self.tr("No prefix or suffix entered. Nothing to do."))
2770
+ return
2614
2771
 
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)
2772
+ renamed = 0
2773
+ failures = []
2620
2774
 
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}")
2775
+ # Work on indices so we can update lists safely
2776
+ indices = []
2777
+ for it in selected_items:
2778
+ idx = self._leaf_index(it)
2779
+ if idx is not None:
2780
+ indices.append((idx, it))
2625
2781
 
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)
2782
+ for idx, it in indices:
2783
+ old_path = self.image_paths[idx]
2784
+ directory, base = os.path.split(old_path)
2629
2785
 
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}")
2786
+ new_base = f"{prefix}{base}{suffix}"
2787
+ new_path = os.path.join(directory, new_base)
2788
+
2789
+ if new_path == old_path:
2790
+ continue
2791
+
2792
+ if os.path.exists(new_path):
2793
+ failures.append((old_path, self.tr("target already exists")))
2794
+ continue
2795
+
2796
+ try:
2797
+ os.rename(old_path, new_path)
2798
+ except Exception as e:
2799
+ failures.append((old_path, str(e)))
2800
+ continue
2801
+
2802
+ # Update internal lists
2803
+ self.image_paths[idx] = new_path
2804
+ self.loaded_images[idx]["file_path"] = new_path
2805
+
2806
+ # Update leaf item
2807
+ flagged = bool(self.loaded_images[idx].get("flagged", False))
2808
+ self._set_leaf_display(it, base_name=new_base, flagged=flagged, full_path=new_path)
2809
+
2810
+ renamed += 1
2811
+
2812
+ # Rebuild so group headers + natural order stay correct
2813
+ self._after_list_changed()
2814
+ self._sync_metrics_flags()
2815
+
2816
+ msg = self.tr("Batch renamed {0} file{1}.").format(renamed, "s" if renamed != 1 else "")
2817
+ if failures:
2818
+ msg += self.tr("\n\n{0} file(s) failed:").format(len(failures))
2819
+ for old, err in failures[:10]:
2820
+ msg += f"\n• {os.path.basename(old)} – {err}"
2821
+ QMessageBox.information(self, self.tr("Batch Rename"), msg)
2633
2822
 
2634
- print(f"Batch renamed {len(selected_items)} items.")
2635
2823
 
2636
2824
  def batch_delete_flagged_images(self):
2637
2825
  """Delete all flagged images."""
2638
2826
  flagged_images = [img for img in self.loaded_images if img['flagged']]
2639
2827
 
2640
2828
  if not flagged_images:
2641
- QMessageBox.information(self, "No Flagged Images", "There are no flagged images to delete.")
2829
+ QMessageBox.information(self, self.tr("No Flagged Images"), self.tr("There are no flagged images to delete."))
2642
2830
  return
2643
2831
 
2644
2832
  confirmation = QMessageBox.question(
2645
2833
  self,
2646
- "Confirm Batch Deletion",
2647
- f"Are you sure you want to permanently delete {len(flagged_images)} flagged images? This action is irreversible.",
2834
+ self.tr("Confirm Batch Deletion"),
2835
+ self.tr("Are you sure you want to permanently delete {0} flagged images? This action is irreversible.").format(len(flagged_images)),
2648
2836
  QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
2649
2837
  QMessageBox.StandardButton.No
2650
2838
  )
@@ -2672,147 +2860,187 @@ class BlinkTab(QWidget):
2672
2860
  self.loaded_images.remove(img)
2673
2861
  self.remove_item_from_tree(file_path)
2674
2862
 
2675
- QMessageBox.information(self, "Batch Deletion", f"Deleted {len(removed_indices)} flagged images.")
2863
+ QMessageBox.information(self, self.tr("Batch Deletion"), self.tr("Deleted {0} flagged images.").format(len(removed_indices)))
2676
2864
 
2677
2865
  # 🔁 refresh tree + metrics (no recompute)
2678
2866
  self._after_list_changed(removed_indices)
2679
2867
 
2680
2868
  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.")
2869
+ """Move all flagged images to a selected directory AND remove them from the blink list."""
2870
+ flagged_indices = [i for i, e in enumerate(self.loaded_images) if e.get("flagged", False)]
2871
+ if not flagged_indices:
2872
+ QMessageBox.information(self, self.tr("No Flagged Images"), self.tr("There are no flagged images to move."))
2686
2873
  return
2687
2874
 
2688
- # Select destination directory
2689
- destination_dir = QFileDialog.getExistingDirectory(self, "Select Destination Folder", "")
2875
+ destination_dir = QFileDialog.getExistingDirectory(self, self.tr("Select Destination Folder"), "")
2690
2876
  if not destination_dir:
2691
- return # User canceled
2877
+ return
2692
2878
 
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)
2879
+ failures = []
2697
2880
 
2881
+ # Move first (use current paths from indices)
2882
+ for i in flagged_indices:
2883
+ src_path = self.image_paths[i]
2884
+ dest_path = os.path.join(destination_dir, os.path.basename(src_path))
2698
2885
  try:
2699
2886
  os.rename(src_path, dest_path)
2700
- print(f"Moved flagged image from {src_path} to {dest_path}")
2701
2887
  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
2888
+ failures.append((src_path, str(e)))
2705
2889
 
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
2890
+ # Remove from lists ONLY if move succeeded
2891
+ # Build a set of indices to remove: those that did NOT fail
2892
+ failed_src = {p for p, _ in failures}
2893
+ removed_indices = [i for i in flagged_indices if self.image_paths[i] not in failed_src]
2711
2894
 
2712
- # Update tree view
2713
- self.remove_item_from_tree(src_path)
2714
- self.add_item_to_tree(dest_path)
2895
+ removed_indices = sorted(set(removed_indices), reverse=True)
2896
+ for idx in removed_indices:
2897
+ if 0 <= idx < len(self.image_paths):
2898
+ del self.image_paths[idx]
2899
+ if 0 <= idx < len(self.loaded_images):
2900
+ del self.loaded_images[idx]
2901
+
2902
+ if removed_indices:
2903
+ self._after_list_changed(removed_indices)
2904
+
2905
+ if failures:
2906
+ msg = self.tr("Moved {0} flagged file(s). {1} failed:").format(len(removed_indices), len(failures))
2907
+ for p, err in failures[:10]:
2908
+ msg += f"\n• {os.path.basename(p)} – {err}"
2909
+ QMessageBox.warning(self, self.tr("Batch Move"), msg)
2910
+ else:
2911
+ QMessageBox.information(self, self.tr("Batch Move"), self.tr("Moved and removed {0} flagged image(s).").format(len(removed_indices)))
2715
2912
 
2716
- QMessageBox.information(self, "Batch Move", f"Moved {len(flagged_images)} flagged images.")
2717
- self._after_list_changed(removed_indices=None)
2718
2913
 
2719
2914
  def move_items(self):
2720
- """Move selected images *and* remove them from the tree+metrics."""
2721
- selected_items = self.fileTree.selectedItems()
2915
+ """Move selected leaf images to a selected directory AND remove them from the blink list."""
2916
+ selected_items = [it for it in self.fileTree.selectedItems() if it and it.childCount() == 0]
2722
2917
  if not selected_items:
2723
- QMessageBox.warning(self, "Warning", "No items selected for moving.")
2918
+ QMessageBox.warning(self, self.tr("Warning"), self.tr("No individual image items selected for moving."))
2724
2919
  return
2725
2920
 
2726
- # Ask where to move
2727
- new_dir = QFileDialog.getExistingDirectory(self,
2728
- "Select Destination Folder",
2729
- "")
2921
+ new_dir = QFileDialog.getExistingDirectory(self, self.tr("Select Destination Folder"), "")
2730
2922
  if not new_dir:
2731
2923
  return
2732
2924
 
2733
- # Keep track of which on‐disk paths we actually moved
2734
- moved_old_paths = []
2735
2925
  removed_indices = []
2926
+ failures = []
2736
2927
 
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:
2928
+ # Collect (idx, old_path, item) first to avoid index drift
2929
+ triplets = []
2930
+ for it in selected_items:
2931
+ p = self._leaf_path(it)
2932
+ if not p:
2742
2933
  continue
2743
- removed_indices.append(self.image_paths.index(old_path))
2934
+ try:
2935
+ idx = self.image_paths.index(p)
2936
+ except ValueError:
2937
+ continue
2938
+ triplets.append((idx, p, it))
2744
2939
 
2745
- new_path = os.path.join(new_dir, name)
2940
+ for idx, old_path, it in triplets:
2941
+ base = os.path.basename(old_path)
2942
+ new_path = os.path.join(new_dir, base)
2746
2943
  try:
2747
2944
  os.rename(old_path, new_path)
2748
2945
  except Exception as e:
2749
- QMessageBox.critical(self, "Error", f"Failed to move {old_path}: {e}")
2946
+ failures.append((old_path, str(e)))
2750
2947
  continue
2751
2948
 
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)
2949
+ removed_indices.append(idx)
2757
2950
 
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]
2951
+ # remove leaf from tree immediately (optional; _after_list_changed will rebuild anyway)
2952
+ #parent = it.parent() or self.fileTree.invisibleRootItem()
2953
+ #parent.removeChild(it)
2762
2954
 
2763
- self._after_list_changed(removed_indices)
2764
- print(f"Moved and removed {len(removed_indices)} items.")
2955
+ # Purge arrays descending
2956
+ removed_indices = sorted(set(removed_indices), reverse=True)
2957
+ for idx in removed_indices:
2958
+ if 0 <= idx < len(self.image_paths):
2959
+ del self.image_paths[idx]
2960
+ if 0 <= idx < len(self.loaded_images):
2961
+ del self.loaded_images[idx]
2765
2962
 
2963
+ if removed_indices:
2964
+ self._after_list_changed(removed_indices)
2766
2965
 
2966
+ if failures:
2967
+ msg = self.tr("Moved {0} file(s). {1} failed:").format(len(removed_indices), len(failures))
2968
+ for old, err in failures[:10]:
2969
+ msg += f"\n• {os.path.basename(old)} – {err}"
2970
+ QMessageBox.warning(self, self.tr("Move Selected Items"), msg)
2971
+ else:
2972
+ QMessageBox.information(self, self.tr("Move Selected Items"), self.tr("Moved and removed {0} item(s).").format(len(removed_indices)))
2767
2973
 
2768
2974
  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
-
2975
+ """Delete selected leaf images from disk and remove them from the blink list."""
2976
+ selected_items = [it for it in self.fileTree.selectedItems() if it and it.childCount() == 0]
2772
2977
  if not selected_items:
2773
- QMessageBox.warning(self, "Warning", "No items selected for deletion.")
2978
+ QMessageBox.warning(self, self.tr("Warning"), self.tr("No individual image items selected for deletion."))
2774
2979
  return
2775
2980
 
2776
- # Confirmation dialog
2777
2981
  reply = QMessageBox.question(
2778
2982
  self,
2779
- 'Confirm Deletion',
2780
- f"Are you sure you want to permanently delete {len(selected_items)} selected images? This action is irreversible.",
2983
+ self.tr("Confirm Deletion"),
2984
+ self.tr("Are you sure you want to permanently delete {0} selected images? This action is irreversible.").format(len(selected_items)),
2781
2985
  QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
2782
2986
  QMessageBox.StandardButton.No
2783
2987
  )
2988
+ if reply != QMessageBox.StandardButton.Yes:
2989
+ return
2784
2990
 
2785
2991
  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):
2992
+ failures = []
2993
+
2994
+ # Snapshot first
2995
+ triplets = []
2996
+ for it in selected_items:
2997
+ p = self._leaf_path(it)
2998
+ if not p:
2999
+ continue
3000
+ try:
3001
+ idx = self.image_paths.index(p)
3002
+ except ValueError:
3003
+ continue
3004
+ triplets.append((idx, p, it))
3005
+
3006
+ for idx, path, it in triplets:
3007
+ try:
3008
+ os.remove(path)
3009
+ except Exception as e:
3010
+ failures.append((path, str(e)))
3011
+ continue
3012
+
3013
+ removed_indices.append(idx)
3014
+
3015
+ # remove from tree immediately (optional)
3016
+ parent = it.parent() or self.fileTree.invisibleRootItem()
3017
+ parent.removeChild(it)
3018
+
3019
+ # Purge arrays descending
3020
+ removed_indices = sorted(set(removed_indices), reverse=True)
3021
+ for idx in removed_indices:
3022
+ if 0 <= idx < len(self.image_paths):
2805
3023
  del self.image_paths[idx]
3024
+ if 0 <= idx < len(self.loaded_images):
2806
3025
  del self.loaded_images[idx]
2807
3026
 
2808
- # Clear preview
2809
- self.preview_label.clear()
2810
- self.preview_label.setText('No image selected.')
2811
- self.current_image = None
3027
+ # Clear preview safely
3028
+ self.preview_label.clear()
3029
+ self.preview_label.setText(self.tr("No image selected."))
3030
+ self.current_pixmap = None
2812
3031
 
2813
- # 🔁 refresh tree + metrics (no recompute)
3032
+ if removed_indices:
2814
3033
  self._after_list_changed(removed_indices)
2815
3034
 
3035
+ if failures:
3036
+ msg = self.tr("Deleted {0} file(s). {1} failed:").format(len(removed_indices), len(failures))
3037
+ for p, err in failures[:10]:
3038
+ msg += f"\n• {os.path.basename(p)} – {err}"
3039
+ QMessageBox.warning(self, self.tr("Delete Selected Items"), msg)
3040
+ else:
3041
+ QMessageBox.information(self, self.tr("Delete Selected Items"), self.tr("Deleted {0} item(s).").format(len(removed_indices)))
3042
+
3043
+
2816
3044
  def eventFilter(self, source, event):
2817
3045
  """Handle mouse events for dragging."""
2818
3046
  if source == self.scroll_area.viewport():
@@ -2875,16 +3103,14 @@ class BlinkTab(QWidget):
2875
3103
  self.on_item_clicked(cur, 0)
2876
3104
 
2877
3105
  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
3106
  if img_array.dtype == np.uint8:
2881
3107
  arr8 = img_array
2882
3108
  elif img_array.dtype == np.uint16:
2883
- # downscale 16-bit → 8-bit
2884
3109
  arr8 = (img_array.astype(np.float32) / 65535.0 * 255.0).clip(0,255).astype(np.uint8)
2885
3110
  else:
2886
- # assume float in [0..1]
2887
- arr8 = (img_array.clip(0.0, 1.0) * 255.0).astype(np.uint8)
3111
+ # display-only normalize floats outside 0..1
3112
+ f01 = self._ensure_float01(img_array)
3113
+ arr8 = (f01 * 255.0).astype(np.uint8)
2888
3114
 
2889
3115
  h, w = arr8.shape[:2]
2890
3116
  buffer = arr8.tobytes()