setiastrosuitepro 1.6.1.post1__py3-none-any.whl → 1.6.4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (139) hide show
  1. setiastro/images/Background_startup.jpg +0 -0
  2. setiastro/images/rotatearbitrary.png +0 -0
  3. setiastro/qml/ResourceMonitor.qml +126 -0
  4. setiastro/saspro/__main__.py +162 -25
  5. setiastro/saspro/_generated/build_info.py +2 -1
  6. setiastro/saspro/abe.py +62 -11
  7. setiastro/saspro/aberration_ai.py +3 -3
  8. setiastro/saspro/add_stars.py +5 -2
  9. setiastro/saspro/astrobin_exporter.py +3 -0
  10. setiastro/saspro/astrospike_python.py +3 -1
  11. setiastro/saspro/autostretch.py +4 -2
  12. setiastro/saspro/backgroundneutral.py +60 -9
  13. setiastro/saspro/batch_convert.py +3 -0
  14. setiastro/saspro/batch_renamer.py +3 -0
  15. setiastro/saspro/blemish_blaster.py +3 -0
  16. setiastro/saspro/blink_comparator_pro.py +474 -251
  17. setiastro/saspro/cheat_sheet.py +50 -15
  18. setiastro/saspro/clahe.py +27 -1
  19. setiastro/saspro/comet_stacking.py +103 -38
  20. setiastro/saspro/convo.py +3 -0
  21. setiastro/saspro/copyastro.py +3 -0
  22. setiastro/saspro/cosmicclarity.py +70 -45
  23. setiastro/saspro/crop_dialog_pro.py +28 -1
  24. setiastro/saspro/curve_editor_pro.py +18 -0
  25. setiastro/saspro/debayer.py +3 -0
  26. setiastro/saspro/doc_manager.py +40 -17
  27. setiastro/saspro/fitsmodifier.py +3 -0
  28. setiastro/saspro/frequency_separation.py +8 -2
  29. setiastro/saspro/function_bundle.py +18 -16
  30. setiastro/saspro/generate_translations.py +715 -1
  31. setiastro/saspro/ghs_dialog_pro.py +3 -0
  32. setiastro/saspro/graxpert.py +3 -0
  33. setiastro/saspro/gui/main_window.py +364 -92
  34. setiastro/saspro/gui/mixins/dock_mixin.py +119 -7
  35. setiastro/saspro/gui/mixins/file_mixin.py +7 -0
  36. setiastro/saspro/gui/mixins/geometry_mixin.py +105 -5
  37. setiastro/saspro/gui/mixins/menu_mixin.py +29 -0
  38. setiastro/saspro/gui/mixins/toolbar_mixin.py +33 -10
  39. setiastro/saspro/gui/statistics_dialog.py +47 -0
  40. setiastro/saspro/halobgon.py +29 -3
  41. setiastro/saspro/histogram.py +3 -0
  42. setiastro/saspro/history_explorer.py +2 -0
  43. setiastro/saspro/i18n.py +22 -10
  44. setiastro/saspro/image_combine.py +3 -0
  45. setiastro/saspro/image_peeker_pro.py +3 -0
  46. setiastro/saspro/imageops/stretch.py +5 -13
  47. setiastro/saspro/isophote.py +3 -0
  48. setiastro/saspro/legacy/numba_utils.py +64 -47
  49. setiastro/saspro/linear_fit.py +3 -0
  50. setiastro/saspro/live_stacking.py +13 -2
  51. setiastro/saspro/mask_creation.py +3 -0
  52. setiastro/saspro/mfdeconv.py +5 -0
  53. setiastro/saspro/morphology.py +30 -5
  54. setiastro/saspro/multiscale_decomp.py +713 -256
  55. setiastro/saspro/nbtorgb_stars.py +12 -2
  56. setiastro/saspro/numba_utils.py +148 -47
  57. setiastro/saspro/ops/scripts.py +77 -17
  58. setiastro/saspro/ops/settings.py +1 -43
  59. setiastro/saspro/perfect_palette_picker.py +1 -0
  60. setiastro/saspro/pixelmath.py +6 -2
  61. setiastro/saspro/plate_solver.py +1 -0
  62. setiastro/saspro/remove_green.py +18 -1
  63. setiastro/saspro/remove_stars.py +136 -162
  64. setiastro/saspro/remove_stars_preset.py +55 -13
  65. setiastro/saspro/resources.py +36 -10
  66. setiastro/saspro/rgb_combination.py +1 -0
  67. setiastro/saspro/rgbalign.py +4 -4
  68. setiastro/saspro/save_options.py +1 -0
  69. setiastro/saspro/selective_color.py +79 -20
  70. setiastro/saspro/sfcc.py +50 -8
  71. setiastro/saspro/shortcuts.py +94 -21
  72. setiastro/saspro/signature_insert.py +3 -0
  73. setiastro/saspro/stacking_suite.py +924 -446
  74. setiastro/saspro/star_alignment.py +291 -331
  75. setiastro/saspro/star_spikes.py +116 -32
  76. setiastro/saspro/star_stretch.py +38 -1
  77. setiastro/saspro/stat_stretch.py +35 -3
  78. setiastro/saspro/status_log_dock.py +1 -1
  79. setiastro/saspro/subwindow.py +63 -2
  80. setiastro/saspro/supernovaasteroidhunter.py +3 -0
  81. setiastro/saspro/swap_manager.py +77 -42
  82. setiastro/saspro/translations/all_source_strings.json +4726 -0
  83. setiastro/saspro/translations/ar_translations.py +4096 -0
  84. setiastro/saspro/translations/de_translations.py +441 -446
  85. setiastro/saspro/translations/es_translations.py +278 -32
  86. setiastro/saspro/translations/fr_translations.py +280 -32
  87. setiastro/saspro/translations/hi_translations.py +3803 -0
  88. setiastro/saspro/translations/integrate_translations.py +38 -1
  89. setiastro/saspro/translations/it_translations.py +1211 -145
  90. setiastro/saspro/translations/ja_translations.py +556 -307
  91. setiastro/saspro/translations/pt_translations.py +3316 -3322
  92. setiastro/saspro/translations/ru_translations.py +3082 -0
  93. setiastro/saspro/translations/saspro_ar.qm +0 -0
  94. setiastro/saspro/translations/saspro_ar.ts +16019 -0
  95. setiastro/saspro/translations/saspro_de.qm +0 -0
  96. setiastro/saspro/translations/saspro_de.ts +14428 -133
  97. setiastro/saspro/translations/saspro_es.qm +0 -0
  98. setiastro/saspro/translations/saspro_es.ts +11503 -7821
  99. setiastro/saspro/translations/saspro_fr.qm +0 -0
  100. setiastro/saspro/translations/saspro_fr.ts +11168 -7812
  101. setiastro/saspro/translations/saspro_hi.qm +0 -0
  102. setiastro/saspro/translations/saspro_hi.ts +14855 -0
  103. setiastro/saspro/translations/saspro_it.qm +0 -0
  104. setiastro/saspro/translations/saspro_it.ts +14347 -7821
  105. setiastro/saspro/translations/saspro_ja.qm +0 -0
  106. setiastro/saspro/translations/saspro_ja.ts +14860 -137
  107. setiastro/saspro/translations/saspro_pt.qm +0 -0
  108. setiastro/saspro/translations/saspro_pt.ts +14904 -137
  109. setiastro/saspro/translations/saspro_ru.qm +0 -0
  110. setiastro/saspro/translations/saspro_ru.ts +11835 -0
  111. setiastro/saspro/translations/saspro_sw.qm +0 -0
  112. setiastro/saspro/translations/saspro_sw.ts +15237 -0
  113. setiastro/saspro/translations/saspro_uk.qm +0 -0
  114. setiastro/saspro/translations/saspro_uk.ts +15248 -0
  115. setiastro/saspro/translations/saspro_zh.qm +0 -0
  116. setiastro/saspro/translations/saspro_zh.ts +10581 -7812
  117. setiastro/saspro/translations/sw_translations.py +3897 -0
  118. setiastro/saspro/translations/uk_translations.py +3929 -0
  119. setiastro/saspro/translations/zh_translations.py +283 -32
  120. setiastro/saspro/versioning.py +36 -5
  121. setiastro/saspro/view_bundle.py +20 -17
  122. setiastro/saspro/wavescale_hdr.py +22 -1
  123. setiastro/saspro/wavescalede.py +23 -1
  124. setiastro/saspro/whitebalance.py +39 -3
  125. setiastro/saspro/widgets/minigame/game.js +991 -0
  126. setiastro/saspro/widgets/minigame/index.html +53 -0
  127. setiastro/saspro/widgets/minigame/style.css +241 -0
  128. setiastro/saspro/widgets/resource_monitor.py +263 -0
  129. setiastro/saspro/widgets/spinboxes.py +18 -0
  130. setiastro/saspro/widgets/wavelet_utils.py +52 -20
  131. setiastro/saspro/wimi.py +100 -80
  132. setiastro/saspro/wims.py +33 -33
  133. setiastro/saspro/window_shelf.py +2 -2
  134. {setiastrosuitepro-1.6.1.post1.dist-info → setiastrosuitepro-1.6.4.dist-info}/METADATA +15 -4
  135. {setiastrosuitepro-1.6.1.post1.dist-info → setiastrosuitepro-1.6.4.dist-info}/RECORD +139 -115
  136. {setiastrosuitepro-1.6.1.post1.dist-info → setiastrosuitepro-1.6.4.dist-info}/WHEEL +0 -0
  137. {setiastrosuitepro-1.6.1.post1.dist-info → setiastrosuitepro-1.6.4.dist-info}/entry_points.txt +0 -0
  138. {setiastrosuitepro-1.6.1.post1.dist-info → setiastrosuitepro-1.6.4.dist-info}/licenses/LICENSE +0 -0
  139. {setiastrosuitepro-1.6.1.post1.dist-info → setiastrosuitepro-1.6.4.dist-info}/licenses/license.txt +0 -0
@@ -14,7 +14,7 @@ from PyQt6.QtWidgets import (
14
14
  QVBoxLayout, QWidget, QTextEdit, QListWidget, QListWidgetItem,
15
15
  QAbstractItemView, QApplication
16
16
  )
17
- from PyQt6.QtGui import QTextCursor
17
+ from PyQt6.QtGui import QTextCursor, QAction
18
18
 
19
19
  if TYPE_CHECKING:
20
20
  from PyQt6.QtWidgets import QAction
@@ -194,6 +194,99 @@ class DockMixin:
194
194
  except Exception:
195
195
  pass
196
196
 
197
+ def _init_resource_monitor_overlay(self):
198
+ """Initialize the QML System Resource Monitor as a floating overlay."""
199
+ try:
200
+ from setiastro.saspro.widgets.resource_monitor import SystemMonitorWidget
201
+
202
+ # Create as a child of the central widget or self to sit on top
203
+ # Using self (QMainWindow) allows it to float over everything including status bar if we want,
204
+ # but usually we want it over MDI area. Let's try self first for "floating" feel.
205
+ self.resource_monitor = SystemMonitorWidget(self)
206
+ self.resource_monitor.setObjectName("ResourceMonitorOverlay")
207
+
208
+ # Make it a proper independent window to allow true transparency (translucent background)
209
+ # without black artifacts from parent composition.
210
+ # Fixed: Removed WindowStaysOnTopHint to allow it to be obscured by other apps (Alt-Tab support)
211
+ self.resource_monitor.setWindowFlags(
212
+ Qt.WindowType.Window |
213
+ Qt.WindowType.FramelessWindowHint |
214
+ Qt.WindowType.Tool
215
+ )
216
+
217
+ # Sizing and Transparency
218
+ self.resource_monitor.setFixedSize(200, 60)
219
+ # self.resource_monitor.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, True) # Optional: if we want click-through
220
+
221
+
222
+ # Initial placement (will be updated by resizeEvent)
223
+ self._update_monitor_position()
224
+
225
+ # Defer visibility to MainWindow.showEvent to prevent appearing before main window
226
+ # visible = self.settings.value("ui/resource_monitor_visible", True, type=bool)
227
+ # if visible:
228
+ # self.resource_monitor.show()
229
+ # else:
230
+ # self.resource_monitor.hide()
231
+ except Exception as e:
232
+ print(f"WARNING: Could not initialize System Monitor overlay: {e}")
233
+ self.resource_monitor = None
234
+
235
+ def _toggle_resource_monitor(self, checked: bool):
236
+ """Toggle floating monitor visibility."""
237
+ if hasattr(self, 'resource_monitor') and self.resource_monitor:
238
+ if checked:
239
+ self.resource_monitor.show()
240
+ self._update_monitor_position()
241
+ else:
242
+ self.resource_monitor.hide()
243
+ self.settings.setValue("ui/resource_monitor_visible", checked)
244
+
245
+ def _update_monitor_position(self):
246
+ """Snap monitor to bottom-right corner or restore saved position."""
247
+ if hasattr(self, 'resource_monitor') and self.resource_monitor:
248
+ from PyQt6.QtCore import QPoint
249
+
250
+ # Check for saved position first
251
+ saved_x = self.settings.value("ui/resource_monitor_pos_x", type=int)
252
+ saved_y = self.settings.value("ui/resource_monitor_pos_y", type=int)
253
+
254
+ if saved_x != 0 and saved_y != 0: # Basic validity check (0,0 is unlikely to be desired but also default if missing)
255
+ # Actually 0,0 is valid but type=int returns 0 if missing.
256
+ # Let's check string existence to be safer or just accept 0 if set.
257
+ # Checking existence via `contains` is better but value() logic is ok for now.
258
+ if self.settings.contains("ui/resource_monitor_pos_x"):
259
+ self.resource_monitor.move(saved_x, saved_y)
260
+ self.resource_monitor.raise_()
261
+ return
262
+
263
+ m = 5 # margin
264
+
265
+ screen = self.screen()
266
+ geom = screen.availableGeometry()
267
+
268
+ mw = self.resource_monitor.width()
269
+ mh = self.resource_monitor.height()
270
+
271
+ x = geom.x() + geom.width() - mw - m
272
+ y = geom.y() + geom.height() - mh - m
273
+
274
+ self.resource_monitor.move(x, y)
275
+ self.resource_monitor.raise_()
276
+
277
+ # We need to hook resizeEvent to call _update_monitor_position.
278
+ # Since this is a mixin, we can't easily override resizeEvent of the MainWindow without being careful.
279
+ # Best way: install an event filter on self, or since we are a mixin mixed into MainWindow,
280
+ # we can rely on MainWindow calling a specific method or we can patch it...
281
+ # Actually, MainWindow likely has resizeEvent.
282
+ # simpler: QTimer check? No.
283
+ # Correct way for Mixin: The MainWindow class should call something.
284
+ # BUT, I can just installEventFilter(self) ? No, infinite loop risk.
285
+ #
286
+ # Let's use the 'GeometryMixin' or just add a standard method `_on_resize_for_monitor`
287
+ # and assume I can hook it in MainWindow.py.
288
+
289
+
197
290
  # ❌ Remove this old line; it let random mouse-over updates hijack the dock:
198
291
  # self.currentDocumentChanged.disconnect(self.header_viewer.set_document) # if previously connected
199
292
  # (If you prefer to keep the signal for explicit tab switches, it's fine to leave
@@ -210,13 +303,28 @@ class DockMixin:
210
303
 
211
304
  # Friendly ordering for common ones; others follow alphabetically.
212
305
  order_hint = {
213
- "Explorer": 10,
214
- "Console / Status": 20,
215
- "Header Viewer": 30,
216
- "Layers": 40,
217
- "Window Shelf": 50,
218
- "Command Search": 60,
306
+ self.tr("Explorer"): 10,
307
+ self.tr("Console / Status"): 20,
308
+ self.tr("Header Viewer"): 30,
309
+ self.tr("Layers"): 40,
310
+ self.tr("Window Shelf"): 50,
311
+ self.tr("Command Search"): 60,
219
312
  }
313
+
314
+ # Add special action for overlay monitor
315
+ mon_act = QAction(self.tr("System Monitor"), self)
316
+ mon_act.setCheckable(True)
317
+ mon_act.setChecked(self.settings.value("ui/resource_monitor_visible", True, type=bool))
318
+ mon_act.triggered.connect(self._toggle_resource_monitor)
319
+
320
+ # We need to insert it into the logic that populates the menu.
321
+ # But 'dock_mixin' automates menu from self.findChildren(QDockWidget).
322
+ # So we have to manually inject this action into the "Panels" menu if possible
323
+ # or expose it such that main_window can add it.
324
+ #
325
+ # Easier: allow main_window to add it, or ...
326
+ # If I can't easily see where menu is built, I'll bind it to self.act_toggle_monitor = mon_act
327
+ self.act_toggle_monitor = mon_act
220
328
 
221
329
  def key_fn(d: QDockWidget):
222
330
  t = d.windowTitle()
@@ -224,6 +332,10 @@ class DockMixin:
224
332
 
225
333
  for dock in sorted(docks, key=key_fn):
226
334
  self._register_dock_in_view_menu(dock)
335
+
336
+ if hasattr(self, "act_toggle_monitor"):
337
+ menu.addSeparator()
338
+ menu.addAction(self.act_toggle_monitor)
227
339
 
228
340
  def _add_doc_to_explorer(self, doc):
229
341
  base = self._normalize_base_doc(doc)
@@ -120,6 +120,13 @@ class FileMixin:
120
120
  doc = self.docman.open_path(p) # this emits documentAdded
121
121
  self._log(f"Opened: {p}")
122
122
  self._add_recent_image(p) # âœ... track in MRU
123
+
124
+ # Increment statistics
125
+ try:
126
+ count = self.settings.value("stats/opened_images_count", 0, type=int)
127
+ self.settings.setValue("stats/opened_images_count", count + 1)
128
+ except Exception:
129
+ pass
123
130
  except Exception as e:
124
131
  QMessageBox.warning(self, self.tr("Open failed"), f"{p}\n\n{e}")
125
132
 
@@ -51,12 +51,10 @@ except ImportError:
51
51
  return cv2.resize(arr, (new_w, new_h), interpolation=cv2.INTER_LINEAR)
52
52
 
53
53
 
54
- # Try to import WCS update function
55
- try:
56
- from setiastro.saspro.wcs_utils import update_wcs_after_crop
57
- except ImportError:
58
- update_wcs_after_crop = None
54
+ from setiastro.saspro.wcs_update import update_wcs_after_crop
59
55
 
56
+ import cv2
57
+ import math
60
58
 
61
59
  if TYPE_CHECKING:
62
60
  pass
@@ -209,6 +207,44 @@ class GeometryMixin:
209
207
  except Exception as e:
210
208
  QMessageBox.critical(self, "Rotate 180°", str(e))
211
209
 
210
+ def _exec_geom_rot_any(self):
211
+ sw = self.mdi.activeSubWindow() if hasattr(self, "mdi") else None
212
+ view = sw.widget() if sw else None
213
+ doc = getattr(view, "document", None)
214
+ if doc is None or getattr(doc, "image", None) is None:
215
+ QMessageBox.information(self, self.tr("Rotate..."), self.tr("Active view has no image."))
216
+ return
217
+
218
+ if cv2 is None:
219
+ QMessageBox.warning(self, self.tr("Rotate..."), self.tr("OpenCV (cv2) is required for arbitrary rotation."))
220
+ return
221
+
222
+ dlg = QInputDialog(self)
223
+ dlg.setWindowTitle(self.tr("Rotate..."))
224
+ dlg.setLabelText(self.tr("Angle in degrees (positive = CCW):"))
225
+ dlg.setInputMode(QInputDialog.InputMode.DoubleInput)
226
+ dlg.setDoubleRange(-360.0, 360.0)
227
+ dlg.setDoubleDecimals(2)
228
+ dlg.setDoubleValue(0.0)
229
+ dlg.setWindowFlag(Qt.WindowType.Window, True)
230
+
231
+ try:
232
+ from setiastro.saspro.resources import rotatearbitrary_path
233
+ dlg.setWindowIcon(QIcon(rotatearbitrary_path))
234
+ except Exception:
235
+ pass
236
+
237
+ if dlg.exec() != QDialog.DialogCode.Accepted:
238
+ return
239
+
240
+ angle = float(dlg.doubleValue())
241
+ try:
242
+ self._apply_geom_rot_any_to_doc(doc, angle_deg=angle)
243
+ self._log(f"Rotate ({angle:g}°) applied to active view")
244
+ except Exception as e:
245
+ QMessageBox.critical(self, self.tr("Rotate..."), str(e))
246
+
247
+
212
248
  def _exec_geom_rescale(self):
213
249
  """Execute rescale operation on active view with dialog."""
214
250
  sw = self.mdi.activeSubWindow() if hasattr(self, "mdi") else None
@@ -334,6 +370,70 @@ class GeometryMixin:
334
370
 
335
371
  self._apply_geom_with_wcs(doc, out, M_src_to_dst=M, step_name="Rotate 180°")
336
372
 
373
+ def _apply_geom_rot_any_to_doc(self, doc, *, angle_deg: float):
374
+ if cv2 is None:
375
+ raise RuntimeError("cv2 is required for arbitrary rotation")
376
+
377
+ src = np.asarray(doc.image, dtype=np.float32, order="C")
378
+ h, w = src.shape[:2]
379
+
380
+ # Rotation about center
381
+ cx = (w - 1) * 0.5
382
+ cy = (h - 1) * 0.5
383
+
384
+ # OpenCV uses CCW degrees
385
+ A2 = cv2.getRotationMatrix2D((cx, cy), angle_deg, 1.0) # 2x3
386
+
387
+ # Convert to 3x3
388
+ M = np.array([
389
+ [A2[0,0], A2[0,1], A2[0,2]],
390
+ [A2[1,0], A2[1,1], A2[1,2]],
391
+ [0.0, 0.0, 1.0 ],
392
+ ], dtype=np.float32)
393
+
394
+ # Compute output bounds by rotating the four corners
395
+ corners = np.array([
396
+ [0.0, 0.0, 1.0],
397
+ [w - 1.0, 0.0, 1.0],
398
+ [w - 1.0, h - 1.0, 1.0],
399
+ [0.0, h - 1.0, 1.0],
400
+ ], dtype=np.float32).T # 3x4
401
+
402
+ rc = (M @ corners) # 3x4
403
+ xs = rc[0, :]
404
+ ys = rc[1, :]
405
+
406
+ min_x = float(xs.min())
407
+ max_x = float(xs.max())
408
+ min_y = float(ys.min())
409
+ max_y = float(ys.max())
410
+
411
+ out_w = int(math.ceil(max_x - min_x + 1.0))
412
+ out_h = int(math.ceil(max_y - min_y + 1.0))
413
+ if out_w <= 0 or out_h <= 0:
414
+ raise RuntimeError("Invalid output size after rotation")
415
+
416
+ # Shift so that min corner maps to (0,0)
417
+ T = np.array([
418
+ [1.0, 0.0, -min_x],
419
+ [0.0, 1.0, -min_y],
420
+ [0.0, 0.0, 1.0],
421
+ ], dtype=np.float32)
422
+
423
+ M = (T @ M).astype(np.float32) # final src->dst 3x3
424
+
425
+ # Warp
426
+ # cv2.warpPerspective expects (W,H)
427
+ flags = cv2.INTER_LANCZOS4
428
+ if src.ndim == 2:
429
+ out = cv2.warpPerspective(src, M, (out_w, out_h), flags=flags)
430
+ else:
431
+ # warpPerspective works on multi-channel too
432
+ out = cv2.warpPerspective(src, M, (out_w, out_h), flags=flags)
433
+
434
+ self._apply_geom_with_wcs(doc, out, M_src_to_dst=M, step_name=f"Rotate ({angle_deg:g}°)")
435
+
436
+
337
437
  def _apply_geom_rescale_to_doc(self, doc, *, factor: float):
338
438
  """Apply rescale to document with WCS update."""
339
439
  factor = float(max(0.1, min(10.0, factor)))
@@ -25,6 +25,28 @@ class MenuMixin:
25
25
  # This method will be implemented as part of the main window
26
26
  # For now, this is a placeholder showing the mixin pattern
27
27
  pass
28
+
29
+ def _show_statistics(self):
30
+ from setiastro.saspro.gui.statistics_dialog import StatisticsDialog
31
+ dlg = StatisticsDialog(self)
32
+ dlg.exec()
33
+
34
+ def _hook_tool_stats(self, menus):
35
+ if not hasattr(self, "_on_tool_triggered"):
36
+ return
37
+
38
+ seen = set()
39
+ for menu in menus:
40
+ for action in self._iter_menu_actions(menu):
41
+ if action in seen: continue
42
+ seen.add(action)
43
+ if action.isSeparator(): continue
44
+
45
+ try:
46
+ action.triggered.connect(self._on_tool_triggered)
47
+ except Exception:
48
+ pass
49
+
28
50
 
29
51
  def _rebuild_recent_menus(self):
30
52
  """Rebuild the recent files and projects menus."""
@@ -163,6 +185,7 @@ class MenuMixin:
163
185
  m_geom.addAction(self.act_geom_rot_cw)
164
186
  m_geom.addAction(self.act_geom_rot_ccw)
165
187
  m_geom.addAction(self.act_geom_rot_180)
188
+ m_geom.addAction(self.act_geom_rot_any)
166
189
  m_geom.addSeparator()
167
190
  m_geom.addAction(self.act_geom_rescale)
168
191
  m_geom.addSeparator()
@@ -298,6 +321,12 @@ class MenuMixin:
298
321
  m_about.addAction(self.act_check_updates)
299
322
 
300
323
 
324
+ m_about.addSeparator()
325
+ m_about.addAction(self.tr("Statistics..."), self._show_statistics)
326
+
327
+ # Connect tool stats
328
+ self._hook_tool_stats([m_fn, m_tools, mCosmic, m_geom, m_star, m_masks, m_header, m_scripts])
329
+
301
330
  # initialize enabled state + names
302
331
  self.update_undo_redo_action_labels()
303
332
 
@@ -10,6 +10,9 @@ from PyQt6.QtCore import Qt, QTimer, QUrl
10
10
  from PyQt6.QtGui import QAction, QActionGroup, QIcon, QKeySequence, QDesktopServices
11
11
  from PyQt6.QtWidgets import QMenu, QToolButton
12
12
 
13
+ from PyQt6.QtCore import QElapsedTimer
14
+
15
+
13
16
  if TYPE_CHECKING:
14
17
  pass
15
18
 
@@ -20,7 +23,7 @@ from setiastro.saspro.resources import (
20
23
  LInsert_path, rgbcombo_path, rgbextract_path, graxperticon_path,
21
24
  cropicon_path, openfile_path, abeicon_path, undoicon_path, redoicon_path,
22
25
  blastericon_path, hdr_path, invert_path, fliphorizontal_path,
23
- flipvertical_path, rotateclockwise_path, rotatecounterclockwise_path,
26
+ flipvertical_path, rotateclockwise_path, rotatecounterclockwise_path,rotatearbitrary_path,
24
27
  rotate180_path, maskcreate_path, maskapply_path, maskremove_path,
25
28
  pixelmath_path, histogram_path, mosaic_path, rescale_path, staralign_path,
26
29
  platesolve_path, psf_path, supernova_path, starregistration_path,
@@ -73,7 +76,8 @@ class ToolbarMixin:
73
76
 
74
77
  def _init_toolbar(self):
75
78
  # View toolbar (Undo / Redo / Display-Stretch)
76
- tb = DraggableToolBar("View", self)
79
+ tb = DraggableToolBar(self.tr("View"), self)
80
+ tb.setObjectName("View")
77
81
  tb.setSettingsKey("Toolbar/View")
78
82
  self.addToolBar(Qt.ToolBarArea.TopToolBarArea, tb)
79
83
 
@@ -175,7 +179,8 @@ class ToolbarMixin:
175
179
  pass
176
180
 
177
181
  # Functions toolbar
178
- tb_fn = DraggableToolBar("Functions", self)
182
+ tb_fn = DraggableToolBar(self.tr("Functions"), self)
183
+ tb_fn.setObjectName("Functions")
179
184
  tb_fn.setSettingsKey("Toolbar/Functions")
180
185
  self.addToolBar(Qt.ToolBarArea.TopToolBarArea, tb_fn)
181
186
 
@@ -227,7 +232,8 @@ class ToolbarMixin:
227
232
  except Exception:
228
233
  pass
229
234
 
230
- tbCosmic = DraggableToolBar("Cosmic Clarity", self)
235
+ tbCosmic = DraggableToolBar(self.tr("Cosmic Clarity"), self)
236
+ tbCosmic.setObjectName("Cosmic Clarity")
231
237
  tbCosmic.setSettingsKey("Toolbar/Cosmic")
232
238
  self.addToolBar(Qt.ToolBarArea.TopToolBarArea, tbCosmic)
233
239
 
@@ -241,7 +247,8 @@ class ToolbarMixin:
241
247
  except Exception:
242
248
  pass
243
249
 
244
- tb_tl = DraggableToolBar("Tools", self)
250
+ tb_tl = DraggableToolBar(self.tr("Tools"), self)
251
+ tb_tl.setObjectName("Tools")
245
252
  tb_tl.setSettingsKey("Toolbar/Tools")
246
253
  self.addToolBar(Qt.ToolBarArea.TopToolBarArea, tb_tl)
247
254
 
@@ -260,7 +267,8 @@ class ToolbarMixin:
260
267
  except Exception:
261
268
  pass
262
269
 
263
- tb_geom = DraggableToolBar("Geometry", self)
270
+ tb_geom = DraggableToolBar(self.tr("Geometry"), self)
271
+ tb_geom.setObjectName("Geometry")
264
272
  tb_geom.setSettingsKey("Toolbar/Geometry")
265
273
  self.addToolBar(Qt.ToolBarArea.TopToolBarArea, tb_geom)
266
274
 
@@ -272,6 +280,7 @@ class ToolbarMixin:
272
280
  tb_geom.addAction(self.act_geom_rot_cw)
273
281
  tb_geom.addAction(self.act_geom_rot_ccw)
274
282
  tb_geom.addAction(self.act_geom_rot_180)
283
+ tb_geom.addAction(self.act_geom_rot_any)
275
284
  tb_geom.addSeparator()
276
285
  tb_geom.addAction(self.act_geom_rescale)
277
286
  tb_geom.addSeparator()
@@ -283,7 +292,8 @@ class ToolbarMixin:
283
292
  except Exception:
284
293
  pass
285
294
 
286
- tb_star = DraggableToolBar("Star Stuff", self)
295
+ tb_star = DraggableToolBar(self.tr("Star Stuff"), self)
296
+ tb_star.setObjectName("Star Stuff")
287
297
  tb_star.setSettingsKey("Toolbar/StarStuff")
288
298
  self.addToolBar(Qt.ToolBarArea.TopToolBarArea, tb_star)
289
299
 
@@ -308,7 +318,8 @@ class ToolbarMixin:
308
318
  except Exception:
309
319
  pass
310
320
 
311
- tb_msk = DraggableToolBar("Masks", self)
321
+ tb_msk = DraggableToolBar(self.tr("Masks"), self)
322
+ tb_msk.setObjectName("Masks")
312
323
  tb_msk.setSettingsKey("Toolbar/Masks")
313
324
  self.addToolBar(Qt.ToolBarArea.TopToolBarArea, tb_msk)
314
325
 
@@ -322,7 +333,8 @@ class ToolbarMixin:
322
333
  except Exception:
323
334
  pass
324
335
 
325
- tb_wim = DraggableToolBar("What's In My...", self)
336
+ tb_wim = DraggableToolBar(self.tr("What's In My..."), self)
337
+ tb_wim.setObjectName("What's In My...")
326
338
  tb_wim.setSettingsKey("Toolbar/WhatsInMy")
327
339
  self.addToolBar(Qt.ToolBarArea.TopToolBarArea, tb_wim)
328
340
 
@@ -335,7 +347,8 @@ class ToolbarMixin:
335
347
  except Exception:
336
348
  pass
337
349
 
338
- tb_bundle = DraggableToolBar("Bundles", self)
350
+ tb_bundle = DraggableToolBar(self.tr("Bundles"), self)
351
+ tb_bundle.setObjectName("Bundles")
339
352
  tb_bundle.setSettingsKey("Toolbar/Bundles")
340
353
  self.addToolBar(Qt.ToolBarArea.TopToolBarArea, tb_bundle)
341
354
 
@@ -873,6 +886,12 @@ class ToolbarMixin:
873
886
  self.act_geom_rot_180.setStatusTip(self.tr("Rotate image 180°"))
874
887
  self.act_geom_rot_180.triggered.connect(self._exec_geom_rot_180)
875
888
 
889
+ self.act_geom_rot_any = QAction(QIcon(rotatearbitrary_path), self.tr("Rotate..."), self)
890
+ self.act_geom_rot_any.setIconVisibleInMenu(True)
891
+ self.act_geom_rot_any.setStatusTip(self.tr("Rotate image by an arbitrary angle (degrees)"))
892
+ self.act_geom_rot_any.triggered.connect(self._exec_geom_rot_any)
893
+
894
+
876
895
  self.act_geom_rescale = QAction(QIcon(rescale_path), self.tr("Rescale..."), self)
877
896
  self.act_geom_rescale.setIconVisibleInMenu(True)
878
897
  self.act_geom_rescale.setStatusTip(self.tr("Rescale image by a factor"))
@@ -1196,6 +1215,7 @@ class ToolbarMixin:
1196
1215
  reg("geom_rotate_clockwise", self.act_geom_rot_cw)
1197
1216
  reg("geom_rotate_counterclockwise",self.act_geom_rot_ccw)
1198
1217
  reg("geom_rotate_180", self.act_geom_rot_180)
1218
+ reg("geom_rotate_any", self.act_geom_rot_any)
1199
1219
  reg("geom_rescale", self.act_geom_rescale)
1200
1220
  reg("project_new", self.act_project_new)
1201
1221
  reg("project_save", self.act_project_save)
@@ -1387,6 +1407,7 @@ class ToolbarMixin:
1387
1407
  a.setStatusTip(tip)
1388
1408
  a.setEnabled(False)
1389
1409
 
1410
+
1390
1411
  def _sync_link_action_state(self):
1391
1412
  g = self._current_group_of_active()
1392
1413
  self.act_link_group.blockSignals(True)
@@ -1432,6 +1453,7 @@ class ToolbarMixin:
1432
1453
  QTimer.singleShot(0, self.update_undo_redo_action_labels)
1433
1454
 
1434
1455
  def _refresh_mask_action_states(self):
1456
+
1435
1457
  active_doc = self._active_doc()
1436
1458
 
1437
1459
  can_apply = bool(active_doc and self._list_candidate_mask_sources(exclude_doc=active_doc))
@@ -1455,3 +1477,4 @@ class ToolbarMixin:
1455
1477
  if hasattr(self, "act_hide_mask"):
1456
1478
  self.act_hide_mask.setEnabled(has_mask and overlay_on)
1457
1479
 
1480
+
@@ -0,0 +1,47 @@
1
+ from PyQt6.QtWidgets import QDialog, QVBoxLayout, QLabel, QFormLayout, QPushButton
2
+ from PyQt6.QtCore import Qt, QSettings
3
+ from PyQt6.QtGui import QIcon
4
+
5
+ class StatisticsDialog(QDialog):
6
+ def __init__(self, parent=None):
7
+ super().__init__(parent)
8
+ self.setWindowTitle(self.tr("App Statistics"))
9
+ self.setWindowFlags(self.windowFlags() & ~Qt.WindowType.WindowContextHelpButtonHint)
10
+ self.resize(300, 200)
11
+
12
+ # Settings to read stats
13
+ self.settings = QSettings("SetiAstro", "SetiAstroSuitePro")
14
+
15
+ layout = QVBoxLayout(self)
16
+
17
+ form_layout = QFormLayout()
18
+
19
+ # Time Spent
20
+ total_seconds = self.settings.value("stats/total_time_seconds", 0, type=float)
21
+ days = int(total_seconds // 86400)
22
+ hours = int((total_seconds % 86400) // 3600)
23
+ minutes = int((total_seconds % 3600) // 60)
24
+
25
+ time_str = f"{days} {self.tr('Days')}, {hours} {self.tr('Hours')}, {minutes} {self.tr('Minutes')}"
26
+ if days == 0:
27
+ time_str = f"{hours} {self.tr('Hours')}, {minutes} {self.tr('Minutes')}"
28
+
29
+ self.lbl_time = QLabel(time_str)
30
+ form_layout.addRow(self.tr("Time Spent:"), self.lbl_time)
31
+
32
+ # Images Opened
33
+ images_count = self.settings.value("stats/opened_images_count", 0, type=int)
34
+ self.lbl_images = QLabel(str(images_count))
35
+ form_layout.addRow(self.tr("Images Opened:"), self.lbl_images)
36
+
37
+ # Tools Opened
38
+ tools_count = self.settings.value("stats/opened_tools_count", 0, type=int)
39
+ self.lbl_tools = QLabel(str(tools_count))
40
+ form_layout.addRow(self.tr("Tools Opened:"), self.lbl_tools)
41
+
42
+ layout.addLayout(form_layout)
43
+
44
+ # Close button
45
+ btn_close = QPushButton(self.tr("Close"))
46
+ btn_close.clicked.connect(self.accept)
47
+ layout.addWidget(btn_close, alignment=Qt.AlignmentFlag.AlignRight)
@@ -249,6 +249,9 @@ class HaloBGonDialogPro(QDialog):
249
249
  def __init__(self, parent, doc, icon: Optional[QIcon] = None):
250
250
  super().__init__(parent)
251
251
  self.setWindowTitle("Halo-B-Gon")
252
+ self.setWindowFlag(Qt.WindowType.Window, True)
253
+ self.setWindowModality(Qt.WindowModality.NonModal)
254
+ self.setModal(False)
252
255
  if icon:
253
256
  try: self.setWindowIcon(icon)
254
257
  except Exception as e:
@@ -427,7 +430,8 @@ class HaloBGonDialogPro(QDialog):
427
430
  except Exception:
428
431
  pass
429
432
 
430
- self.accept()
433
+ # Dialog stays open - refresh document for next operation
434
+ self._refresh_document_from_active()
431
435
  return
432
436
  else:
433
437
  # Fallback: try legacy spawner if present; else warn and overwrite.
@@ -437,7 +441,8 @@ class HaloBGonDialogPro(QDialog):
437
441
  if callable(spawner):
438
442
  title = self.doc.display_name() if hasattr(self.doc, "display_name") else "Image"
439
443
  spawner(out, f"{title} [Halo-B-Gon]")
440
- self.accept()
444
+ # Dialog stays open - refresh document for next operation
445
+ self._refresh_document_from_active()
441
446
  return
442
447
  else:
443
448
  QMessageBox.warning(
@@ -448,11 +453,32 @@ class HaloBGonDialogPro(QDialog):
448
453
 
449
454
  # Overwrite current (original behavior)
450
455
  self._apply_overwrite(out)
451
- self.accept()
456
+ # Dialog stays open - refresh document for next operation
457
+ self._refresh_document_from_active()
452
458
 
453
459
  except Exception as e:
454
460
  QMessageBox.critical(self, "Halo-B-Gon", f"Failed to apply:\n{e}")
455
461
 
462
+ def _refresh_document_from_active(self):
463
+ """
464
+ Refresh the dialog's document reference to the currently active document.
465
+ This allows reusing the same dialog on different images.
466
+ """
467
+ try:
468
+ main = self.parent()
469
+ if main and hasattr(main, "_active_doc"):
470
+ new_doc = main._active_doc()
471
+ if new_doc is not None and new_doc is not self.doc:
472
+ self.doc = new_doc
473
+ # Refresh preview for new document
474
+ self.orig = np.clip(np.asarray(new_doc.image, dtype=np.float32), 0.0, 1.0)
475
+ disp = self.orig
476
+ if disp.ndim == 2: disp = disp[..., None].repeat(3, axis=2)
477
+ elif disp.ndim == 3 and disp.shape[2] == 1: disp = disp.repeat(3, axis=2)
478
+ self._disp_base = disp
479
+ self._update_preview()
480
+ except Exception:
481
+ pass
456
482
 
457
483
 
458
484
  def _reset(self):
@@ -30,6 +30,9 @@ class HistogramDialog(QDialog):
30
30
  def __init__(self, parent, document):
31
31
  super().__init__(parent)
32
32
  self.setWindowTitle(self.tr("Histogram"))
33
+ self.setWindowFlag(Qt.WindowType.Window, True)
34
+ self.setWindowModality(Qt.WindowModality.NonModal)
35
+ self.setModal(False)
33
36
  self.doc = document
34
37
  self.image = _to_float_preserve(document.image)
35
38
 
@@ -436,6 +436,8 @@ class HistoryExplorerDialog(QDialog):
436
436
  def __init__(self, document, parent=None):
437
437
  super().__init__(parent)
438
438
  self.setWindowTitle("History Explorer")
439
+ self.setWindowFlag(Qt.WindowType.Window, True)
440
+ self.setWindowModality(Qt.WindowModality.NonModal)
439
441
  self.setModal(False)
440
442
  self.doc = document
441
443
 
setiastro/saspro/i18n.py CHANGED
@@ -25,22 +25,34 @@ AVAILABLE_LANGUAGES: Dict[str, str] = {
25
25
  "de": "Deutsch",
26
26
  "pt": "Português",
27
27
  "ja": "日本語",
28
+ "hi": "हिन्दी",
29
+ "sw": "Kiswahili",
30
+ "uk": "Українська",
31
+ "ru": "Русский",
32
+ "ar": "العربية",
28
33
  }
29
34
 
30
35
 
31
36
  def get_translations_dir() -> str:
32
37
  """Get the path to the translations directory."""
33
- # When running from source or installed package
38
+ # Source / installed package location
34
39
  module_dir = os.path.dirname(os.path.abspath(__file__))
35
- translations_dir = os.path.join(module_dir, "translations")
36
-
37
- # Fallback for PyInstaller frozen builds
38
- if hasattr(os.sys, '_MEIPASS'):
39
- frozen_dir = os.path.join(os.sys._MEIPASS, "translations")
40
- if os.path.exists(frozen_dir):
41
- return frozen_dir
42
-
43
- return translations_dir
40
+ pkg_dir = os.path.join(module_dir, "translations")
41
+
42
+ # PyInstaller frozen builds
43
+ if hasattr(os.sys, "_MEIPASS"):
44
+ # New bundle layout (preferred)
45
+ frozen_internal = os.path.join(os.sys._MEIPASS, "_internal", "translations")
46
+ if os.path.exists(frozen_internal):
47
+ return frozen_internal
48
+
49
+ # Legacy bundle layout fallback
50
+ frozen_legacy = os.path.join(os.sys._MEIPASS, "translations")
51
+ if os.path.exists(frozen_legacy):
52
+ return frozen_legacy
53
+
54
+ return pkg_dir
55
+
44
56
 
45
57
 
46
58
  def get_available_languages() -> Dict[str, str]: