setiastrosuitepro 1.6.10__py3-none-any.whl → 1.7.0.post2__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 (51) hide show
  1. setiastro/images/colorwheel.svg +97 -0
  2. setiastro/images/narrowbandnormalization.png +0 -0
  3. setiastro/images/planetarystacker.png +0 -0
  4. setiastro/saspro/__main__.py +1 -1
  5. setiastro/saspro/_generated/build_info.py +2 -2
  6. setiastro/saspro/aberration_ai.py +49 -11
  7. setiastro/saspro/aberration_ai_preset.py +29 -3
  8. setiastro/saspro/backgroundneutral.py +73 -33
  9. setiastro/saspro/blink_comparator_pro.py +116 -71
  10. setiastro/saspro/convo.py +9 -6
  11. setiastro/saspro/curve_editor_pro.py +72 -22
  12. setiastro/saspro/curves_preset.py +249 -47
  13. setiastro/saspro/doc_manager.py +178 -11
  14. setiastro/saspro/gui/main_window.py +305 -66
  15. setiastro/saspro/gui/mixins/dock_mixin.py +245 -24
  16. setiastro/saspro/gui/mixins/file_mixin.py +35 -16
  17. setiastro/saspro/gui/mixins/menu_mixin.py +32 -1
  18. setiastro/saspro/gui/mixins/toolbar_mixin.py +135 -11
  19. setiastro/saspro/histogram.py +179 -7
  20. setiastro/saspro/imageops/narrowband_normalization.py +816 -0
  21. setiastro/saspro/imageops/serloader.py +972 -0
  22. setiastro/saspro/imageops/starbasedwhitebalance.py +23 -52
  23. setiastro/saspro/imageops/stretch.py +66 -15
  24. setiastro/saspro/legacy/numba_utils.py +25 -48
  25. setiastro/saspro/live_stacking.py +24 -4
  26. setiastro/saspro/multiscale_decomp.py +30 -17
  27. setiastro/saspro/narrowband_normalization.py +1618 -0
  28. setiastro/saspro/numba_utils.py +0 -55
  29. setiastro/saspro/ops/script_editor.py +5 -0
  30. setiastro/saspro/ops/scripts.py +119 -0
  31. setiastro/saspro/remove_green.py +1 -1
  32. setiastro/saspro/resources.py +4 -0
  33. setiastro/saspro/ser_stack_config.py +74 -0
  34. setiastro/saspro/ser_stacker.py +2310 -0
  35. setiastro/saspro/ser_stacker_dialog.py +1500 -0
  36. setiastro/saspro/ser_tracking.py +206 -0
  37. setiastro/saspro/serviewer.py +1258 -0
  38. setiastro/saspro/sfcc.py +602 -214
  39. setiastro/saspro/shortcuts.py +35 -16
  40. setiastro/saspro/stacking_suite.py +332 -87
  41. setiastro/saspro/star_alignment.py +243 -122
  42. setiastro/saspro/stat_stretch.py +220 -31
  43. setiastro/saspro/subwindow.py +2 -4
  44. setiastro/saspro/whitebalance.py +24 -0
  45. setiastro/saspro/widgets/resource_monitor.py +122 -74
  46. {setiastrosuitepro-1.6.10.dist-info → setiastrosuitepro-1.7.0.post2.dist-info}/METADATA +2 -2
  47. {setiastrosuitepro-1.6.10.dist-info → setiastrosuitepro-1.7.0.post2.dist-info}/RECORD +51 -40
  48. {setiastrosuitepro-1.6.10.dist-info → setiastrosuitepro-1.7.0.post2.dist-info}/WHEEL +0 -0
  49. {setiastrosuitepro-1.6.10.dist-info → setiastrosuitepro-1.7.0.post2.dist-info}/entry_points.txt +0 -0
  50. {setiastrosuitepro-1.6.10.dist-info → setiastrosuitepro-1.7.0.post2.dist-info}/licenses/LICENSE +0 -0
  51. {setiastrosuitepro-1.6.10.dist-info → setiastrosuitepro-1.7.0.post2.dist-info}/licenses/license.txt +0 -0
@@ -12,13 +12,43 @@ from PyQt6.QtCore import Qt, QTimer
12
12
  from PyQt6.QtWidgets import (
13
13
  QDockWidget, QPlainTextEdit, QTreeWidget, QTreeWidgetItem,
14
14
  QVBoxLayout, QWidget, QTextEdit, QListWidget, QListWidgetItem,
15
- QAbstractItemView, QApplication
15
+ QAbstractItemView, QApplication, QLineEdit, QMenu
16
16
  )
17
- from PyQt6.QtGui import QTextCursor, QAction
17
+ from PyQt6.QtGui import QTextCursor, QAction, QGuiApplication
18
18
 
19
19
  if TYPE_CHECKING:
20
20
  from PyQt6.QtWidgets import QAction
21
21
 
22
+ import os
23
+
24
+ GLYPHS = "■●◆▲▪▫•◼◻◾◽🔗"
25
+
26
+ def _strip_ui_decorations(text: str) -> str:
27
+ """
28
+ Strip UI-only decorations from titles:
29
+ - Qt mnemonics (&)
30
+ - link badges like "[LINK]"
31
+ - your glyph badges
32
+ - file extension (optional, but nice for Explorer)
33
+ """
34
+ if not text:
35
+ return ""
36
+ s = str(text)
37
+
38
+ # remove mnemonics
39
+ s = s.replace("&", "")
40
+
41
+ # remove common prefixes/badges
42
+ s = s.replace("[LINK]", "").strip()
43
+
44
+ # remove glyph badges
45
+ s = s.translate({ord(ch): None for ch in GLYPHS})
46
+
47
+ # collapse whitespace
48
+ s = " ".join(s.split())
49
+
50
+ return s
51
+
22
52
 
23
53
  class DockMixin:
24
54
  """
@@ -105,14 +135,44 @@ class DockMixin:
105
135
  self._view_panels_menu.removeAction(action)
106
136
 
107
137
  def _init_explorer_dock(self):
108
- self.explorer = QListWidget()
109
- # Enter/Return or single-activation: focus if open, else open
138
+ host = QWidget(self)
139
+ lay = QVBoxLayout(host)
140
+ lay.setContentsMargins(4, 4, 4, 4)
141
+ lay.setSpacing(4)
142
+
143
+ # Optional filter box (super useful)
144
+ self.explorer_filter = QLineEdit(host)
145
+ self.explorer_filter.setPlaceholderText(self.tr("Filter open documents…"))
146
+ self.explorer_filter.textChanged.connect(self._explorer_apply_filter)
147
+ lay.addWidget(self.explorer_filter)
148
+
149
+ self.explorer = QTreeWidget(host)
150
+ self.explorer.setObjectName("ExplorerTree")
151
+ self.explorer.setColumnCount(3)
152
+ self.explorer.setHeaderLabels([self.tr("Document"), self.tr("Dims"), self.tr("Type")])
153
+
154
+ # Sorting
155
+ self.explorer.setSortingEnabled(True)
156
+ self.explorer.header().setSortIndicatorShown(True)
157
+ self.explorer.sortByColumn(0, Qt.SortOrder.AscendingOrder)
158
+
159
+ # Selection/activation behavior
160
+ self.explorer.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
110
161
  self.explorer.itemActivated.connect(self._activate_or_open_from_explorer)
111
- # Double-click: same behavior
112
- self.explorer.itemDoubleClicked.connect(self._activate_or_open_from_explorer)
162
+
163
+ # Inline rename support
164
+ self.explorer.setEditTriggers(
165
+ QAbstractItemView.EditTrigger.EditKeyPressed |
166
+ QAbstractItemView.EditTrigger.SelectedClicked
167
+ )
168
+ self.explorer.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
169
+ self.explorer.customContextMenuRequested.connect(self._on_explorer_context_menu)
170
+ self.explorer.itemChanged.connect(self._on_explorer_item_changed)
171
+
172
+ lay.addWidget(self.explorer)
113
173
 
114
174
  dock = QDockWidget(self.tr("Explorer"), self)
115
- dock.setWidget(self.explorer)
175
+ dock.setWidget(host)
116
176
  dock.setObjectName("ExplorerDock")
117
177
  self.addDockWidget(Qt.DockWidgetArea.LeftDockWidgetArea, dock)
118
178
 
@@ -341,35 +401,196 @@ class DockMixin:
341
401
  base = self._normalize_base_doc(doc)
342
402
 
343
403
  # de-dupe by identity on base
344
- for i in range(self.explorer.count()):
345
- it = self.explorer.item(i)
346
- if it.data(Qt.ItemDataRole.UserRole) is base:
347
- # refresh text in case dims/name changed
348
- it.setText(self._format_explorer_title(base))
404
+ for i in range(self.explorer.topLevelItemCount()):
405
+ it = self.explorer.topLevelItem(i)
406
+ if it.data(0, Qt.ItemDataRole.UserRole) is base:
407
+ self._refresh_explorer_row(it, base)
349
408
  return
350
409
 
351
- item = QListWidgetItem(self._format_explorer_title(base))
352
- item.setData(Qt.ItemDataRole.UserRole, base)
410
+ it = QTreeWidgetItem()
411
+ it.setData(0, Qt.ItemDataRole.UserRole, base)
412
+
413
+ # Make name editable; other columns read-only
414
+ it.setFlags(it.flags() | Qt.ItemFlag.ItemIsEditable | Qt.ItemFlag.ItemIsSelectable | Qt.ItemFlag.ItemIsEnabled)
415
+
416
+ self._refresh_explorer_row(it, base)
417
+
353
418
  fp = (base.metadata or {}).get("file_path")
354
419
  if fp:
355
- item.setToolTip(fp)
356
- self.explorer.addItem(item)
420
+ it.setToolTip(0, fp)
421
+
422
+ self.explorer.addTopLevelItem(it)
357
423
 
358
424
  # keep row label in sync with edits/resizes/renames
359
425
  try:
360
- base.changed.connect(lambda *_: self._update_explorer_item_for_doc(base))
426
+ base.changed.connect(lambda *_, d=base: self._update_explorer_item_for_doc(d))
361
427
  except Exception:
362
428
  pass
363
429
 
430
+
364
431
  def _remove_doc_from_explorer(self, doc):
365
- """
366
- Remove either the exact doc or its base (handles ROI proxies).
367
- """
368
432
  base = self._normalize_base_doc(doc)
369
- for i in range(self.explorer.count()):
370
- it = self.explorer.item(i)
371
- d = it.data(Qt.ItemDataRole.UserRole)
433
+ for i in range(self.explorer.topLevelItemCount()):
434
+ it = self.explorer.topLevelItem(i)
435
+ d = it.data(0, Qt.ItemDataRole.UserRole)
372
436
  if d is doc or d is base:
373
- self.explorer.takeItem(i)
437
+ self.explorer.takeTopLevelItem(i)
374
438
  break
375
439
 
440
+
441
+ def _update_explorer_item_for_doc(self, doc):
442
+ for i in range(self.explorer.topLevelItemCount()):
443
+ it = self.explorer.topLevelItem(i)
444
+ if it.data(0, Qt.ItemDataRole.UserRole) is doc:
445
+ self._refresh_explorer_row(it, doc)
446
+ return
447
+
448
+ def _refresh_explorer_row(self, item, doc):
449
+ # Column 0: display name (NO glyph decorations)
450
+ name = _strip_ui_decorations(doc.display_name() or "Untitled")
451
+
452
+ name_no_ext, _ext = os.path.splitext(name)
453
+ if name_no_ext:
454
+ name = name_no_ext
455
+
456
+ item.setText(0, name)
457
+
458
+ # Column 1: dims
459
+ dims = ""
460
+ try:
461
+ import numpy as np
462
+ arr = getattr(doc, "image", None)
463
+ if isinstance(arr, np.ndarray) and arr.size:
464
+ h, w = arr.shape[:2]
465
+ c = arr.shape[2] if arr.ndim == 3 else 1
466
+ dims = f"{h}×{w}×{c}"
467
+ except Exception:
468
+ pass
469
+ item.setText(1, dims)
470
+
471
+ # Column 2: type/bit-depth (whatever you have available)
472
+ md = (doc.metadata or {})
473
+ bit = md.get("bit_depth") or md.get("dtype") or ""
474
+ kind = md.get("format") or md.get("doc_type") or ""
475
+ t = " / ".join([s for s in (str(kind), str(bit)) if s and s != "None"])
476
+ item.setText(2, t)
477
+
478
+ def _on_explorer_item_changed(self, item, col: int):
479
+ if col != 0:
480
+ return
481
+
482
+ doc = item.data(0, Qt.ItemDataRole.UserRole)
483
+ if doc is None:
484
+ return
485
+
486
+ new_name = (item.text(0) or "").strip()
487
+ if not new_name:
488
+ # revert to current doc name
489
+ self._refresh_explorer_row(item, doc)
490
+ return
491
+
492
+ # Avoid infinite loops: only apply if changed
493
+ cur = _strip_ui_decorations(doc.display_name() or "Untitled")
494
+ cur_no_ext, _ = os.path.splitext(cur)
495
+ cur = cur_no_ext or cur
496
+ if new_name == cur:
497
+ return
498
+
499
+ try:
500
+ doc.metadata["display_name"] = new_name
501
+ except Exception:
502
+ # if metadata missing or immutable, revert
503
+ self._refresh_explorer_row(item, doc)
504
+ return
505
+
506
+ try:
507
+ doc.changed.emit()
508
+ except Exception:
509
+ pass
510
+
511
+ def _on_explorer_context_menu(self, pos):
512
+ it = self.explorer.itemAt(pos)
513
+ if it is None:
514
+ return
515
+ doc = it.data(0, Qt.ItemDataRole.UserRole)
516
+ if doc is None:
517
+ return
518
+
519
+ menu = QMenu(self.explorer)
520
+ a_rename = menu.addAction(self.tr("Rename Document…"))
521
+ a_close = menu.addAction(self.tr("Close Document"))
522
+ menu.addSeparator()
523
+ a_copy_path = menu.addAction(self.tr("Copy File Path"))
524
+ a_reveal = menu.addAction(self.tr("Reveal in File Manager"))
525
+ menu.addSeparator()
526
+ a_send_shelf = menu.addAction(self.tr("Send View to Shelf")) # acts on active view for this doc
527
+
528
+ act = menu.exec(self.explorer.viewport().mapToGlobal(pos))
529
+ if act == a_rename:
530
+ # Start inline editing
531
+ self.explorer.editItem(it, 0)
532
+
533
+ elif act == a_close:
534
+ # close only if no other subwindows show it: you already do that in _on_view_about_to_close,
535
+ # but Explorer close is explicit; just close all views of this doc then docman.close_document.
536
+ try:
537
+ self._close_all_views_for_doc(doc)
538
+ except Exception:
539
+ pass
540
+
541
+ elif act == a_copy_path:
542
+ fp = (doc.metadata or {}).get("file_path", "")
543
+ if fp:
544
+ QGuiApplication.clipboard().setText(fp)
545
+
546
+ elif act == a_reveal:
547
+ fp = (doc.metadata or {}).get("file_path", "")
548
+ if fp:
549
+ self._reveal_in_file_manager(fp)
550
+
551
+ elif act == a_send_shelf:
552
+ sw = self._find_subwindow_for_doc(doc)
553
+ if sw and hasattr(sw.widget(), "_send_to_shelf"):
554
+ try:
555
+ sw.widget()._send_to_shelf()
556
+ except Exception:
557
+ pass
558
+
559
+ def _close_all_views_for_doc(self, doc):
560
+ base = self._normalize_base_doc(doc)
561
+ subs = list(self.mdi.subWindowList())
562
+ for sw in subs:
563
+ w = sw.widget()
564
+ if getattr(w, "base_document", None) is base:
565
+ try:
566
+ sw.close()
567
+ except Exception:
568
+ pass
569
+ # If none left (or even if close failed), try docman close defensively
570
+ try:
571
+ self.docman.close_document(base)
572
+ except Exception:
573
+ pass
574
+
575
+
576
+ def _reveal_in_file_manager(self, path: str):
577
+ import sys, os, subprocess
578
+ try:
579
+ if sys.platform.startswith("win"):
580
+ subprocess.Popen(["explorer", "/select,", os.path.normpath(path)])
581
+ elif sys.platform == "darwin":
582
+ subprocess.Popen(["open", "-R", path])
583
+ else:
584
+ # best-effort on Linux
585
+ subprocess.Popen(["xdg-open", os.path.dirname(path)])
586
+ except Exception:
587
+ pass
588
+
589
+ def _explorer_apply_filter(self, text: str):
590
+ t = (text or "").strip().lower()
591
+ for i in range(self.explorer.topLevelItemCount()):
592
+ it = self.explorer.topLevelItem(i)
593
+ name = (it.text(0) or "").lower()
594
+ fp = (it.toolTip(0) or "").lower()
595
+ hide = bool(t) and (t not in name) and (t not in fp)
596
+ it.setHidden(hide)
@@ -7,7 +7,7 @@ import os
7
7
  from typing import TYPE_CHECKING
8
8
 
9
9
  from PyQt6.QtCore import Qt
10
- from PyQt6.QtWidgets import QFileDialog, QMessageBox, QProgressDialog
10
+ from PyQt6.QtWidgets import QFileDialog, QMessageBox, QProgressDialog, QApplication
11
11
 
12
12
  if TYPE_CHECKING:
13
13
  pass
@@ -85,7 +85,6 @@ class FileMixin:
85
85
  self._save_recent_lists()
86
86
  if hasattr(self, "_rebuild_recent_menus"):
87
87
  self._rebuild_recent_menus()
88
- # Extracted FILE methods
89
88
 
90
89
  def open_files(self):
91
90
  # One-stop "All Supported" plus focused groups the user can switch to
@@ -114,21 +113,41 @@ class FileMixin:
114
113
  except Exception:
115
114
  pass
116
115
 
117
- # open each path (doc_manager should emit documentAdded; no manual spawn)
118
- for p in paths:
119
- try:
120
- doc = self.docman.open_path(p) # this emits documentAdded
121
- self._log(f"Opened: {p}")
122
- self._add_recent_image(p) # âœ... track in MRU
123
-
124
- # Increment statistics
116
+ # ---- BEGIN batch open (stable placement) ----
117
+ try:
118
+ self._mdi_begin_open_batch(mode="cascade")
119
+ except Exception:
120
+ pass
121
+
122
+ QApplication.setOverrideCursor(Qt.CursorShape.WaitCursor)
123
+ try:
124
+ # open each path (doc_manager should emit documentAdded; no manual spawn)
125
+ for p in paths:
125
126
  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
130
- except Exception as e:
131
- QMessageBox.warning(self, self.tr("Open failed"), f"{p}\n\n{e}")
127
+ _ = self.docman.open_path(p) # emits documentAdded; spawn will happen
128
+ self._log(f"Opened: {p}")
129
+ self._add_recent_image(p) # track MRU
130
+
131
+ # Increment statistics
132
+ try:
133
+ count = self.settings.value("stats/opened_images_count", 0, type=int)
134
+ self.settings.setValue("stats/opened_images_count", count + 1)
135
+ except Exception:
136
+ pass
137
+
138
+ # Let Qt paint newly spawned subwindows as we go
139
+ QApplication.processEvents()
140
+
141
+ except Exception as e:
142
+ QMessageBox.warning(self, self.tr("Open failed"), f"{p}\n\n{e}")
143
+ QApplication.processEvents()
144
+ finally:
145
+ QApplication.restoreOverrideCursor()
146
+ try:
147
+ self._mdi_end_open_batch()
148
+ except Exception:
149
+ pass
150
+
132
151
 
133
152
  def save_active(self):
134
153
  from setiastro.saspro.main_helpers import (
@@ -6,8 +6,9 @@ from __future__ import annotations
6
6
  import os
7
7
  from typing import TYPE_CHECKING
8
8
 
9
- from PyQt6.QtGui import QAction
9
+ from PyQt6.QtGui import QAction, QKeySequence
10
10
  from PyQt6.QtWidgets import QMenu, QToolButton, QWidgetAction
11
+ from PyQt6.QtCore import Qt
11
12
 
12
13
  if TYPE_CHECKING:
13
14
  pass
@@ -120,6 +121,10 @@ class MenuMixin:
120
121
  m_edit = mb.addMenu(self.tr("&Edit"))
121
122
  m_edit.addAction(self.act_undo)
122
123
  m_edit.addAction(self.act_redo)
124
+ m_edit.addSeparator()
125
+ m_edit.addAction(self.act_mono_to_rgb)
126
+ m_edit.addAction(self.act_swap_rb)
127
+
123
128
 
124
129
  # Functions
125
130
  m_fn = mb.addMenu(self.tr("&Functions"))
@@ -169,6 +174,7 @@ class MenuMixin:
169
174
  m_tools.addAction(self.act_freqsep)
170
175
  m_tools.addAction(self.act_image_combine)
171
176
  m_tools.addAction(self.act_multiscale_decomp)
177
+ m_tools.addAction(self.act_narrowband_normalization)
172
178
  m_tools.addAction(self.act_nbtorgb)
173
179
  m_tools.addAction(self.act_ppp)
174
180
  m_tools.addAction(self.act_selective_color)
@@ -199,6 +205,7 @@ class MenuMixin:
199
205
  m_star.addAction(self.act_isophote)
200
206
  m_star.addAction(self.act_live_stacking)
201
207
  m_star.addAction(self.act_mosaic_master)
208
+ m_star.addAction(self.act_planetary_stacker)
202
209
  m_star.addAction(self.act_plate_solve)
203
210
  m_star.addAction(self.act_psf_viewer)
204
211
  m_star.addAction(self.act_rgb_align)
@@ -267,6 +274,14 @@ class MenuMixin:
267
274
  m_view.addAction(self.act_tile_grid)
268
275
  m_view.addSeparator()
269
276
 
277
+ # NEW: Minimize All Views
278
+ self.act_minimize_all_views = QAction(self.tr("Minimize All Views"), self)
279
+ self.act_minimize_all_views.setShortcut(QKeySequence("Ctrl+Shift+M"))
280
+ self.act_minimize_all_views.setShortcutContext(Qt.ShortcutContext.ApplicationShortcut)
281
+ self.act_minimize_all_views.triggered.connect(self._minimize_all_views)
282
+ m_view.addAction(self.act_minimize_all_views)
283
+
284
+ m_view.addSeparator()
270
285
 
271
286
  # a button that shows current group & opens a drop-down
272
287
  self._link_btn = QToolButton(self)
@@ -389,3 +404,19 @@ class MenuMixin:
389
404
  if sub is not None:
390
405
  yield from self._iter_menu_actions(sub)
391
406
 
407
+ def _minimize_all_views(self):
408
+ mdi = getattr(self, "mdi", None)
409
+ if mdi is None:
410
+ return
411
+
412
+ try:
413
+ for sw in mdi.subWindowList():
414
+ try:
415
+ if not sw.isVisible():
416
+ continue
417
+ # Minimize each MDI child
418
+ sw.showMinimized()
419
+ except Exception:
420
+ pass
421
+ except Exception:
422
+ pass