setiastrosuitepro 1.6.0__py3-none-any.whl

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

Potentially problematic release.


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

Files changed (174) hide show
  1. setiastro/__init__.py +2 -0
  2. setiastro/saspro/__init__.py +20 -0
  3. setiastro/saspro/__main__.py +784 -0
  4. setiastro/saspro/_generated/__init__.py +7 -0
  5. setiastro/saspro/_generated/build_info.py +2 -0
  6. setiastro/saspro/abe.py +1295 -0
  7. setiastro/saspro/abe_preset.py +196 -0
  8. setiastro/saspro/aberration_ai.py +694 -0
  9. setiastro/saspro/aberration_ai_preset.py +224 -0
  10. setiastro/saspro/accel_installer.py +218 -0
  11. setiastro/saspro/accel_workers.py +30 -0
  12. setiastro/saspro/add_stars.py +621 -0
  13. setiastro/saspro/astrobin_exporter.py +1007 -0
  14. setiastro/saspro/astrospike.py +153 -0
  15. setiastro/saspro/astrospike_python.py +1839 -0
  16. setiastro/saspro/autostretch.py +196 -0
  17. setiastro/saspro/backgroundneutral.py +560 -0
  18. setiastro/saspro/batch_convert.py +325 -0
  19. setiastro/saspro/batch_renamer.py +519 -0
  20. setiastro/saspro/blemish_blaster.py +488 -0
  21. setiastro/saspro/blink_comparator_pro.py +2923 -0
  22. setiastro/saspro/bundles.py +61 -0
  23. setiastro/saspro/bundles_dock.py +114 -0
  24. setiastro/saspro/cheat_sheet.py +168 -0
  25. setiastro/saspro/clahe.py +342 -0
  26. setiastro/saspro/comet_stacking.py +1377 -0
  27. setiastro/saspro/config.py +38 -0
  28. setiastro/saspro/config_bootstrap.py +40 -0
  29. setiastro/saspro/config_manager.py +316 -0
  30. setiastro/saspro/continuum_subtract.py +1617 -0
  31. setiastro/saspro/convo.py +1397 -0
  32. setiastro/saspro/convo_preset.py +414 -0
  33. setiastro/saspro/copyastro.py +187 -0
  34. setiastro/saspro/cosmicclarity.py +1564 -0
  35. setiastro/saspro/cosmicclarity_preset.py +407 -0
  36. setiastro/saspro/crop_dialog_pro.py +948 -0
  37. setiastro/saspro/crop_preset.py +189 -0
  38. setiastro/saspro/curve_editor_pro.py +2544 -0
  39. setiastro/saspro/curves_preset.py +375 -0
  40. setiastro/saspro/debayer.py +670 -0
  41. setiastro/saspro/debug_utils.py +29 -0
  42. setiastro/saspro/dnd_mime.py +35 -0
  43. setiastro/saspro/doc_manager.py +2634 -0
  44. setiastro/saspro/exoplanet_detector.py +2166 -0
  45. setiastro/saspro/file_utils.py +284 -0
  46. setiastro/saspro/fitsmodifier.py +744 -0
  47. setiastro/saspro/free_torch_memory.py +48 -0
  48. setiastro/saspro/frequency_separation.py +1343 -0
  49. setiastro/saspro/function_bundle.py +1594 -0
  50. setiastro/saspro/ghs_dialog_pro.py +660 -0
  51. setiastro/saspro/ghs_preset.py +284 -0
  52. setiastro/saspro/graxpert.py +634 -0
  53. setiastro/saspro/graxpert_preset.py +287 -0
  54. setiastro/saspro/gui/__init__.py +0 -0
  55. setiastro/saspro/gui/main_window.py +8494 -0
  56. setiastro/saspro/gui/mixins/__init__.py +33 -0
  57. setiastro/saspro/gui/mixins/dock_mixin.py +263 -0
  58. setiastro/saspro/gui/mixins/file_mixin.py +445 -0
  59. setiastro/saspro/gui/mixins/geometry_mixin.py +403 -0
  60. setiastro/saspro/gui/mixins/header_mixin.py +441 -0
  61. setiastro/saspro/gui/mixins/mask_mixin.py +421 -0
  62. setiastro/saspro/gui/mixins/menu_mixin.py +361 -0
  63. setiastro/saspro/gui/mixins/theme_mixin.py +367 -0
  64. setiastro/saspro/gui/mixins/toolbar_mixin.py +1324 -0
  65. setiastro/saspro/gui/mixins/update_mixin.py +309 -0
  66. setiastro/saspro/gui/mixins/view_mixin.py +435 -0
  67. setiastro/saspro/halobgon.py +462 -0
  68. setiastro/saspro/header_viewer.py +445 -0
  69. setiastro/saspro/headless_utils.py +88 -0
  70. setiastro/saspro/histogram.py +753 -0
  71. setiastro/saspro/history_explorer.py +939 -0
  72. setiastro/saspro/image_combine.py +414 -0
  73. setiastro/saspro/image_peeker_pro.py +1596 -0
  74. setiastro/saspro/imageops/__init__.py +37 -0
  75. setiastro/saspro/imageops/mdi_snap.py +292 -0
  76. setiastro/saspro/imageops/scnr.py +36 -0
  77. setiastro/saspro/imageops/starbasedwhitebalance.py +210 -0
  78. setiastro/saspro/imageops/stretch.py +244 -0
  79. setiastro/saspro/isophote.py +1179 -0
  80. setiastro/saspro/layers.py +208 -0
  81. setiastro/saspro/layers_dock.py +714 -0
  82. setiastro/saspro/lazy_imports.py +193 -0
  83. setiastro/saspro/legacy/__init__.py +2 -0
  84. setiastro/saspro/legacy/image_manager.py +2226 -0
  85. setiastro/saspro/legacy/numba_utils.py +3659 -0
  86. setiastro/saspro/legacy/xisf.py +1071 -0
  87. setiastro/saspro/linear_fit.py +534 -0
  88. setiastro/saspro/live_stacking.py +1830 -0
  89. setiastro/saspro/log_bus.py +5 -0
  90. setiastro/saspro/logging_config.py +460 -0
  91. setiastro/saspro/luminancerecombine.py +309 -0
  92. setiastro/saspro/main_helpers.py +201 -0
  93. setiastro/saspro/mask_creation.py +928 -0
  94. setiastro/saspro/masks_core.py +56 -0
  95. setiastro/saspro/mdi_widgets.py +353 -0
  96. setiastro/saspro/memory_utils.py +666 -0
  97. setiastro/saspro/metadata_patcher.py +75 -0
  98. setiastro/saspro/mfdeconv.py +3826 -0
  99. setiastro/saspro/mfdeconv_earlystop.py +71 -0
  100. setiastro/saspro/mfdeconvcudnn.py +3263 -0
  101. setiastro/saspro/mfdeconvsport.py +2382 -0
  102. setiastro/saspro/minorbodycatalog.py +567 -0
  103. setiastro/saspro/morphology.py +382 -0
  104. setiastro/saspro/multiscale_decomp.py +1290 -0
  105. setiastro/saspro/nbtorgb_stars.py +531 -0
  106. setiastro/saspro/numba_utils.py +3044 -0
  107. setiastro/saspro/numba_warmup.py +141 -0
  108. setiastro/saspro/ops/__init__.py +9 -0
  109. setiastro/saspro/ops/command_help_dialog.py +623 -0
  110. setiastro/saspro/ops/command_runner.py +217 -0
  111. setiastro/saspro/ops/commands.py +1594 -0
  112. setiastro/saspro/ops/script_editor.py +1102 -0
  113. setiastro/saspro/ops/scripts.py +1413 -0
  114. setiastro/saspro/ops/settings.py +560 -0
  115. setiastro/saspro/parallel_utils.py +554 -0
  116. setiastro/saspro/pedestal.py +121 -0
  117. setiastro/saspro/perfect_palette_picker.py +1053 -0
  118. setiastro/saspro/pipeline.py +110 -0
  119. setiastro/saspro/pixelmath.py +1600 -0
  120. setiastro/saspro/plate_solver.py +2435 -0
  121. setiastro/saspro/project_io.py +797 -0
  122. setiastro/saspro/psf_utils.py +136 -0
  123. setiastro/saspro/psf_viewer.py +549 -0
  124. setiastro/saspro/pyi_rthook_astroquery.py +95 -0
  125. setiastro/saspro/remove_green.py +314 -0
  126. setiastro/saspro/remove_stars.py +1625 -0
  127. setiastro/saspro/remove_stars_preset.py +404 -0
  128. setiastro/saspro/resources.py +472 -0
  129. setiastro/saspro/rgb_combination.py +207 -0
  130. setiastro/saspro/rgb_extract.py +19 -0
  131. setiastro/saspro/rgbalign.py +723 -0
  132. setiastro/saspro/runtime_imports.py +7 -0
  133. setiastro/saspro/runtime_torch.py +754 -0
  134. setiastro/saspro/save_options.py +72 -0
  135. setiastro/saspro/selective_color.py +1552 -0
  136. setiastro/saspro/sfcc.py +1425 -0
  137. setiastro/saspro/shortcuts.py +2807 -0
  138. setiastro/saspro/signature_insert.py +1099 -0
  139. setiastro/saspro/stacking_suite.py +17712 -0
  140. setiastro/saspro/star_alignment.py +7420 -0
  141. setiastro/saspro/star_alignment_preset.py +329 -0
  142. setiastro/saspro/star_metrics.py +49 -0
  143. setiastro/saspro/star_spikes.py +681 -0
  144. setiastro/saspro/star_stretch.py +470 -0
  145. setiastro/saspro/stat_stretch.py +502 -0
  146. setiastro/saspro/status_log_dock.py +78 -0
  147. setiastro/saspro/subwindow.py +3267 -0
  148. setiastro/saspro/supernovaasteroidhunter.py +1712 -0
  149. setiastro/saspro/swap_manager.py +99 -0
  150. setiastro/saspro/torch_backend.py +89 -0
  151. setiastro/saspro/torch_rejection.py +434 -0
  152. setiastro/saspro/view_bundle.py +1555 -0
  153. setiastro/saspro/wavescale_hdr.py +624 -0
  154. setiastro/saspro/wavescale_hdr_preset.py +100 -0
  155. setiastro/saspro/wavescalede.py +657 -0
  156. setiastro/saspro/wavescalede_preset.py +228 -0
  157. setiastro/saspro/wcs_update.py +374 -0
  158. setiastro/saspro/whitebalance.py +456 -0
  159. setiastro/saspro/widgets/__init__.py +48 -0
  160. setiastro/saspro/widgets/common_utilities.py +305 -0
  161. setiastro/saspro/widgets/graphics_views.py +122 -0
  162. setiastro/saspro/widgets/image_utils.py +518 -0
  163. setiastro/saspro/widgets/preview_dialogs.py +280 -0
  164. setiastro/saspro/widgets/spinboxes.py +275 -0
  165. setiastro/saspro/widgets/themed_buttons.py +13 -0
  166. setiastro/saspro/widgets/wavelet_utils.py +299 -0
  167. setiastro/saspro/window_shelf.py +185 -0
  168. setiastro/saspro/xisf.py +1123 -0
  169. setiastrosuitepro-1.6.0.dist-info/METADATA +266 -0
  170. setiastrosuitepro-1.6.0.dist-info/RECORD +174 -0
  171. setiastrosuitepro-1.6.0.dist-info/WHEEL +4 -0
  172. setiastrosuitepro-1.6.0.dist-info/entry_points.txt +6 -0
  173. setiastrosuitepro-1.6.0.dist-info/licenses/LICENSE +674 -0
  174. setiastrosuitepro-1.6.0.dist-info/licenses/license.txt +2580 -0
@@ -0,0 +1,61 @@
1
+ # pro/bundles.py
2
+ from __future__ import annotations
3
+ from dataclasses import dataclass, field
4
+ from typing import List, Dict, Optional
5
+ from PyQt6.QtCore import QObject, pyqtSignal
6
+
7
+ @dataclass
8
+ class ViewBundle:
9
+ id: str
10
+ name: str
11
+ doc_uids: List[str] = field(default_factory=list) # store stable doc UIDs
12
+
13
+ class BundleManager(QObject):
14
+ changed = pyqtSignal()
15
+ def __init__(self, dm):
16
+ super().__init__()
17
+ self.dm = dm
18
+ self._by_id: Dict[str, ViewBundle] = {}
19
+
20
+ def add(self, b: ViewBundle):
21
+ self._by_id[b.id] = b
22
+ self.changed.emit()
23
+
24
+ def remove(self, bid: str):
25
+ self._by_id.pop(bid, None)
26
+ self.changed.emit()
27
+
28
+ def get(self, bid: str) -> Optional[ViewBundle]:
29
+ return self._by_id.get(bid)
30
+
31
+ def all(self) -> list[ViewBundle]:
32
+ return list(self._by_id.values())
33
+
34
+ def add_doc(self, bid: str, doc_uid: str):
35
+ b = self._by_id.get(bid)
36
+ if not b: return
37
+ if doc_uid not in b.doc_uids:
38
+ b.doc_uids.append(doc_uid)
39
+ self.changed.emit()
40
+
41
+ def remove_doc(self, bid: str, doc_uid: str):
42
+ b = self._by_id.get(bid)
43
+ if not b: return
44
+ if doc_uid in b.doc_uids:
45
+ b.doc_uids.remove(doc_uid)
46
+ self.changed.emit()
47
+
48
+ def docs(self, bid: str):
49
+ b = self._by_id.get(bid)
50
+ if not b: return []
51
+ out = []
52
+ for uid in b.doc_uids:
53
+ d = getattr(self.dm, "find_document_by_uid", None)
54
+ if callable(d):
55
+ doc = d(uid)
56
+ else:
57
+ # fallback: linear scan over open docs if you don't have an index
58
+ doc = next((x for x in getattr(self.dm, "documents", []) if getattr(x, "uid", None) == uid), None)
59
+ if doc:
60
+ out.append(doc)
61
+ return out
@@ -0,0 +1,114 @@
1
+ # pro/bundles_dock.py
2
+ from __future__ import annotations
3
+ from PyQt6.QtWidgets import QDockWidget, QListWidget, QListWidgetItem, QMenu, QInputDialog
4
+ from PyQt6.QtCore import Qt, QMimeData, QByteArray
5
+ import json
6
+
7
+ from setiastro.saspro.dnd_mime import MIME_CMD # you already use this
8
+
9
+ class BundlesDock(QDockWidget):
10
+ def __init__(self, mw, bm, pipelines):
11
+ super().__init__("Bundles", mw)
12
+ self.mw = mw
13
+ self.bm = bm
14
+ self.pipelines = pipelines
15
+
16
+ self.list = QListWidget(self)
17
+ self.list.setSelectionMode(self.list.SelectionMode.SingleSelection)
18
+ self.list.setAcceptDrops(True)
19
+ self.list.setDragEnabled(False)
20
+ self.setWidget(self.list)
21
+
22
+ self.setAllowedAreas(Qt.DockWidgetArea.LeftDockWidgetArea | Qt.DockWidgetArea.RightDockWidgetArea)
23
+ self._refresh()
24
+ bm.changed.connect(self._refresh)
25
+
26
+ self.list.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
27
+ self.list.customContextMenuRequested.connect(self._ctx)
28
+
29
+ def _refresh(self):
30
+ self.list.clear()
31
+ for b in self.bm.all():
32
+ it = QListWidgetItem(f"{b.name} ({len(b.doc_uids)} views)")
33
+ it.setData(Qt.ItemDataRole.UserRole, b.id)
34
+ self.list.addItem(it)
35
+
36
+ def _ctx(self, pos):
37
+ it = self.list.itemAt(pos)
38
+ m = QMenu(self)
39
+ m.addAction("New Bundle…", self._new_bundle)
40
+ if it:
41
+ bid = it.data(Qt.ItemDataRole.UserRole)
42
+ m.addSeparator()
43
+ m.addAction("Rename…", lambda: self._rename(bid))
44
+ m.addAction("Delete", lambda: self._delete(bid))
45
+ m.addSeparator()
46
+ m.addAction("Run Pipeline…", lambda: self._pick_and_run(bid))
47
+ m.exec(self.list.mapToGlobal(pos))
48
+
49
+ def _new_bundle(self):
50
+ name, ok = QInputDialog.getText(self, "New Bundle", "Name:")
51
+ if not ok or not name.strip(): return
52
+ import uuid
53
+ from setiastro.saspro.bundles import ViewBundle
54
+ self.bm.add(ViewBundle(id=uuid.uuid4().hex, name=name.strip()))
55
+
56
+ def _rename(self, bid):
57
+ b = self.bm.get(bid);
58
+ if not b: return
59
+ name, ok = QInputDialog.getText(self, "Rename Bundle", "Name:", text=b.name)
60
+ if ok and name.strip():
61
+ b.name = name.strip()
62
+ self.bm.changed.emit()
63
+
64
+ def _delete(self, bid):
65
+ self.bm.remove(bid)
66
+
67
+ # --- DnD: add docs or run commands ---
68
+ def dragEnterEvent(self, e):
69
+ if e.mimeData().hasFormat(MIME_CMD):
70
+ e.acceptProposedAction(); return
71
+ # Let your explorer/subwindow provide a doc UID mime if you have one; example below:
72
+ if e.mimeData().hasFormat("application/x-saspro-doc-uid"):
73
+ e.acceptProposedAction(); return
74
+ e.ignore()
75
+
76
+ def dropEvent(self, e):
77
+ it = self.list.itemAt(e.position().toPoint())
78
+ if not it:
79
+ e.ignore(); return
80
+ bid = it.data(Qt.ItemDataRole.UserRole)
81
+
82
+ md: QMimeData = e.mimeData()
83
+ # 1) drop a command/pipeline onto a bundle -> run across all docs in the bundle
84
+ if md.hasFormat(MIME_CMD):
85
+ try:
86
+ payload = json.loads(bytes(md.data(MIME_CMD)).decode("utf-8"))
87
+ except Exception:
88
+ e.ignore(); return
89
+ docs = self.bm.docs(bid)
90
+ if not docs:
91
+ e.ignore(); return
92
+ # Pipeline support via "pipeline:<id>" (see MW patch below)
93
+ self.mw._run_payload_on_docs(payload, docs)
94
+ e.acceptProposedAction(); return
95
+
96
+ # 2) drop doc(s) onto a bundle -> add
97
+ if md.hasFormat("application/x-saspro-doc-uid"):
98
+ uid = bytes(md.data("application/x-saspro-doc-uid")).decode("utf-8")
99
+ self.bm.add_doc(bid, uid)
100
+ e.acceptProposedAction(); return
101
+
102
+ e.ignore()
103
+
104
+ # quick picker to run a pipeline without DnD
105
+ def _pick_and_run(self, bid):
106
+ plist = self.pipelines.all()
107
+ if not plist:
108
+ return
109
+ names = [p.name for p in plist]
110
+ i, ok = QInputDialog.getItem(self, "Run Pipeline", "Pick:", names, 0, False)
111
+ if not ok: return
112
+ p = plist[names.index(i)]
113
+ payload = {"command_id": f"pipeline:{p.id}", "preset": {}}
114
+ self.mw._run_payload_on_docs(payload, self.bm.docs(bid))
@@ -0,0 +1,168 @@
1
+ # pro/cheat_sheet.py
2
+ """
3
+ Keyboard shortcut cheat sheet dialog.
4
+
5
+ Displays all keyboard shortcuts and mouse gestures in a tabbed dialog.
6
+ """
7
+
8
+ from PyQt6.QtWidgets import (
9
+ QDialog, QVBoxLayout, QHBoxLayout, QTabWidget, QWidget,
10
+ QTableWidget, QTableWidgetItem, QHeaderView, QPushButton,
11
+ QApplication, QMessageBox
12
+ )
13
+ from PyQt6.QtGui import QAction, QShortcut, QKeySequence
14
+
15
+
16
+ def _qs_to_str(seq: QKeySequence) -> str:
17
+ """Convert a QKeySequence to a human-readable string."""
18
+ return seq.toString(QKeySequence.SequenceFormat.NativeText).strip()
19
+
20
+
21
+ def _clean_text(text: str) -> str:
22
+ """Remove common Unicode decoration from text."""
23
+ if not text:
24
+ return ""
25
+ # Remove common ellipsis, arrows, etc.
26
+ return text.replace("…", "").replace("→", "->").replace("←", "<-").strip()
27
+
28
+
29
+ def _uniq_keep_order(items):
30
+ """Return unique items preserving order."""
31
+ seen = set()
32
+ out = []
33
+ for x in items:
34
+ if x in seen:
35
+ continue
36
+ seen.add(x)
37
+ out.append(x)
38
+ return out
39
+
40
+
41
+ def _seqs_for_action(act: QAction):
42
+ """Get non-empty key sequences for an action."""
43
+ seqs = [s for s in act.shortcuts() or []] or ([act.shortcut()] if act.shortcut() else [])
44
+ return [s for s in seqs if not s.isEmpty()]
45
+
46
+
47
+ def _where_for_action(act: QAction) -> str:
48
+ """Determine where an action is available (Menus/Toolbar or Window)."""
49
+ if act.parent():
50
+ pn = act.parent().__class__.__name__
51
+ if pn.startswith("QMenu") or pn.startswith("QToolBar"):
52
+ return "Menus/Toolbar"
53
+ return "Window"
54
+
55
+
56
+ def _describe_action(act: QAction) -> str:
57
+ """Get a human-readable description for an action."""
58
+ return _clean_text(act.statusTip() or act.toolTip() or act.text() or act.objectName() or "Action")
59
+
60
+
61
+ def _describe_shortcut(sc: QShortcut) -> str:
62
+ """Get a human-readable description for a shortcut."""
63
+ return _clean_text(sc.property("hint") or sc.whatsThis() or sc.objectName() or "Shortcut")
64
+
65
+
66
+ def _where_for_shortcut(sc: QShortcut) -> str:
67
+ """Determine where a shortcut is available."""
68
+ par = sc.parent()
69
+ return par.__class__.__name__ if par is not None else "Window"
70
+
71
+
72
+ class CheatSheetDialog(QDialog):
73
+ """
74
+ Dialog showing all keyboard shortcuts and mouse gestures.
75
+
76
+ Displays two tabs:
77
+ - Keyboard shortcuts (from QActions)
78
+ - Mouse/drag gestures
79
+ """
80
+
81
+ def __init__(self, parent, keyboard_rows, gesture_rows):
82
+ super().__init__(parent)
83
+ self.setWindowTitle("Keyboard Shortcut Cheat Sheet")
84
+ self.resize(780, 520)
85
+
86
+ self._keyboard_rows = keyboard_rows
87
+ self._gesture_rows = gesture_rows
88
+
89
+ tabs = QTabWidget(self)
90
+
91
+ # --- Keyboard tab ---
92
+ pg_keys = QWidget(tabs)
93
+ v1 = QVBoxLayout(pg_keys)
94
+ tbl_keys = QTableWidget(0, 3, pg_keys)
95
+ tbl_keys.setHorizontalHeaderLabels(["Shortcut", "Action", "Where"])
96
+ tbl_keys.verticalHeader().setVisible(False)
97
+ tbl_keys.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers)
98
+ tbl_keys.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows)
99
+ tbl_keys.setSelectionMode(QTableWidget.SelectionMode.SingleSelection)
100
+ tbl_keys.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.ResizeToContents)
101
+ tbl_keys.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch)
102
+ tbl_keys.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeMode.ResizeToContents)
103
+ v1.addWidget(tbl_keys)
104
+
105
+ # Populate keyboard shortcuts
106
+ for s, action, where in keyboard_rows:
107
+ r = tbl_keys.rowCount()
108
+ tbl_keys.insertRow(r)
109
+ tbl_keys.setItem(r, 0, QTableWidgetItem(s))
110
+ tbl_keys.setItem(r, 1, QTableWidgetItem(action))
111
+ tbl_keys.setItem(r, 2, QTableWidgetItem(where))
112
+
113
+ # --- Mouse/Drag tab ---
114
+ pg_mouse = QWidget(tabs)
115
+ v2 = QVBoxLayout(pg_mouse)
116
+ tbl_mouse = QTableWidget(0, 3, pg_mouse)
117
+ tbl_mouse.setHorizontalHeaderLabels(["Gesture", "Context", "Effect"])
118
+ tbl_mouse.verticalHeader().setVisible(False)
119
+ tbl_mouse.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers)
120
+ tbl_mouse.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows)
121
+ tbl_mouse.setSelectionMode(QTableWidget.SelectionMode.SingleSelection)
122
+ tbl_mouse.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.ResizeToContents)
123
+ tbl_mouse.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeMode.ResizeToContents)
124
+ tbl_mouse.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeMode.Stretch)
125
+ v2.addWidget(tbl_mouse)
126
+
127
+ # Populate mouse gestures
128
+ for gesture, context, effect in gesture_rows:
129
+ r = tbl_mouse.rowCount()
130
+ tbl_mouse.insertRow(r)
131
+ tbl_mouse.setItem(r, 0, QTableWidgetItem(gesture))
132
+ tbl_mouse.setItem(r, 1, QTableWidgetItem(context))
133
+ tbl_mouse.setItem(r, 2, QTableWidgetItem(effect))
134
+
135
+ tabs.addTab(pg_keys, "Base Keyboard")
136
+ tabs.addTab(pg_mouse, "Additional & Mouse & Drag")
137
+
138
+ # Buttons
139
+ btns = QHBoxLayout()
140
+ btns.addStretch(1)
141
+
142
+ b_copy = QPushButton("Copy")
143
+ b_copy.clicked.connect(self._copy_all)
144
+ b_close = QPushButton("Close")
145
+ b_close.clicked.connect(self.accept)
146
+ btns.addWidget(b_copy)
147
+ btns.addWidget(b_close)
148
+
149
+ top = QVBoxLayout(self)
150
+ top.addWidget(tabs)
151
+ top.addLayout(btns)
152
+
153
+ def _copy_all(self):
154
+ """Copy all shortcuts to clipboard as plain text."""
155
+ lines = []
156
+ lines.append("== Keyboard ==")
157
+ for s, a, w in self._keyboard_rows:
158
+ lines.append(f"{s:20} {a} [{w}]")
159
+ lines.append("")
160
+ lines.append("== Mouse & Drag ==")
161
+ for g, c, e in self._gesture_rows:
162
+ lines.append(f"{g:24} {c:18} {e}")
163
+ QApplication.clipboard().setText("\n".join(lines))
164
+ QMessageBox.information(self, "Copied", "Cheat sheet copied to clipboard.")
165
+
166
+
167
+ # Legacy alias for backward compatibility
168
+ _CheatSheetDialog = CheatSheetDialog
@@ -0,0 +1,342 @@
1
+ # pro/clahe.py
2
+ from __future__ import annotations
3
+ import numpy as np
4
+ import cv2
5
+
6
+ from PyQt6.QtCore import Qt, QTimer
7
+ from PyQt6.QtGui import QImage, QPixmap, QIcon
8
+ from PyQt6.QtWidgets import (
9
+ QDialog, QVBoxLayout, QHBoxLayout, QGroupBox, QGridLayout,
10
+ QLabel, QPushButton, QSlider, QGraphicsScene,
11
+ QGraphicsPixmapItem, QMessageBox
12
+ )
13
+
14
+ # Import centralized widgets
15
+ from setiastro.saspro.widgets.graphics_views import ZoomableGraphicsView
16
+ from setiastro.saspro.widgets.image_utils import extract_mask_resized as _get_active_mask_resized
17
+ from setiastro.saspro.widgets.themed_buttons import themed_toolbtn
18
+
19
+
20
+ # ----------------------- Core -----------------------
21
+ def apply_clahe(image: np.ndarray, clip_limit: float = 2.0, tile_grid_size: tuple = (8, 8)) -> np.ndarray:
22
+ # ... (unchanged)
23
+ if image is None:
24
+ raise ValueError("image is None")
25
+ arr = np.asarray(image, dtype=np.float32)
26
+ arr = np.clip(arr, 0.0, 1.0)
27
+ was_hw1 = (arr.ndim == 3 and arr.shape[2] == 1)
28
+ if arr.ndim == 3 and arr.shape[2] == 3:
29
+ lab = cv2.cvtColor((arr * 255.0).astype(np.uint8), cv2.COLOR_RGB2LAB)
30
+ l, a, b = cv2.split(lab)
31
+ clahe = cv2.createCLAHE(clipLimit=float(clip_limit), tileGridSize=tuple(tile_grid_size))
32
+ cl = clahe.apply(l)
33
+ limg = cv2.merge((cl, a, b))
34
+ enhanced = cv2.cvtColor(limg, cv2.COLOR_LAB2RGB).astype(np.float32) / 255.0
35
+ return np.clip(enhanced, 0.0, 1.0)
36
+ mono = arr.squeeze()
37
+ clahe = cv2.createCLAHE(clipLimit=float(clip_limit), tileGridSize=tuple(tile_grid_size))
38
+ cl = clahe.apply((mono * 255.0).astype(np.uint8)).astype(np.float32) / 255.0
39
+ cl = np.clip(cl, 0.0, 1.0)
40
+ if was_hw1:
41
+ cl = cl[..., None]
42
+ return cl
43
+
44
+ # Note: _get_active_mask_resized imported from setiastro.saspro.widgets.image_utils
45
+
46
+ def apply_clahe_to_doc(doc, preset: dict | None):
47
+ """
48
+ Apply CLAHE to doc.image using a preset.
49
+
50
+ Backward compatible:
51
+ - old presets: {"clip_limit": 2.0, "tile": 8} # tile count across min dimension
52
+ - new presets: {"clip_limit": 2.0, "tile_px": 128} # tile size in pixels
53
+ """
54
+ if doc is None or getattr(doc, "image", None) is None:
55
+ raise RuntimeError("Document has no image.")
56
+
57
+ img = np.asarray(doc.image)
58
+
59
+ # --- preset decode (supports old + new) ---
60
+ p = preset or {}
61
+ clip = float(p.get("clip_limit", 2.0))
62
+
63
+ # Resolve tile_grid_size for OpenCV
64
+ if "tile_px" in p:
65
+ tile_px = int(p.get("tile_px", 128))
66
+ h, w = img.shape[:2]
67
+ s = float(min(h, w))
68
+ tile_px = max(8, tile_px)
69
+ n = int(round(s / float(tile_px)))
70
+ n = max(2, min(n, 128))
71
+ tile_grid = (n, n)
72
+ else:
73
+ # legacy: treat "tile" as OpenCV tileGridSize count (tiles across)
74
+ tile = int(p.get("tile", 8))
75
+ tile = max(2, min(tile, 128))
76
+ tile_grid = (tile, tile)
77
+
78
+ out = apply_clahe(img, clip_limit=clip, tile_grid_size=tile_grid)
79
+ out = np.clip(out, 0.0, 1.0).astype(np.float32, copy=False)
80
+
81
+ # Blend with active mask if present
82
+ H, W = out.shape[:2]
83
+ m = _get_active_mask_resized(doc, H, W)
84
+ if m is not None:
85
+ base = np.asarray(doc.image, dtype=np.float32)
86
+ if base.dtype.kind in "ui":
87
+ maxv = float(np.iinfo(base.dtype).max)
88
+ base = base / max(1.0, maxv)
89
+ else:
90
+ base = np.clip(base, 0.0, 1.0)
91
+
92
+ if out.ndim == 3:
93
+ if base.ndim == 2:
94
+ base = base[:, :, None].repeat(out.shape[2], axis=2)
95
+ elif base.ndim == 3 and base.shape[2] == 1:
96
+ base = base.repeat(out.shape[2], axis=2)
97
+ M = np.repeat(m[:, :, None], out.shape[2], axis=2).astype(np.float32)
98
+ out = np.clip(base * (1.0 - M) + out * M, 0.0, 1.0)
99
+ else:
100
+ if base.ndim == 3 and base.shape[2] == 1:
101
+ base = base.squeeze(axis=2)
102
+ out = np.clip(base * (1.0 - m) + out * m, 0.0, 1.0)
103
+
104
+ # Commit
105
+ if hasattr(doc, "set_image"):
106
+ doc.set_image(out, step_name="CLAHE")
107
+ elif hasattr(doc, "apply_numpy"):
108
+ doc.apply_numpy(out, step_name="CLAHE")
109
+ else:
110
+ doc.image = out
111
+
112
+ # ----------------------- Dialog -----------------------
113
+ class CLAHEDialogPro(QDialog):
114
+ def __init__(self, parent, doc, icon: QIcon | None = None):
115
+ super().__init__(parent)
116
+ self.setWindowTitle("CLAHE")
117
+ if icon:
118
+ try: self.setWindowIcon(icon)
119
+ except Exception as e:
120
+ import logging
121
+ logging.debug(f"Exception suppressed: {type(e).__name__}: {e}")
122
+
123
+ self.doc = doc
124
+ self.orig = np.clip(np.asarray(doc.image, dtype=np.float32), 0.0, 1.0)
125
+ disp = self.orig
126
+ if disp.ndim == 2: disp = disp[..., None].repeat(3, axis=2)
127
+ elif disp.ndim == 3 and disp.shape[2] == 1: disp = disp.repeat(3, axis=2)
128
+ self._disp_base = disp
129
+
130
+ v = QVBoxLayout(self)
131
+
132
+ # ---- Params (unchanged) ----
133
+ grp = QGroupBox("CLAHE Parameters"); grid = QGridLayout(grp)
134
+ self.s_clip = QSlider(Qt.Orientation.Horizontal); self.s_clip.setRange(1, 40); self.s_clip.setValue(20)
135
+ self.lbl_clip = QLabel("2.0")
136
+ self.s_clip.valueChanged.connect(lambda val: self.lbl_clip.setText(f"{val/10.0:.1f}"))
137
+ self.s_clip.valueChanged.connect(self._debounce_preview)
138
+
139
+ # tile size slider (pixels) — intuitive control
140
+ self.s_tile = QSlider(Qt.Orientation.Horizontal)
141
+ self.s_tile.setRange(8, 512) # 4 is pointless; you clamp to >=8 anyway
142
+ self.s_tile.setSingleStep(8)
143
+ self.s_tile.setPageStep(64)
144
+ self.s_tile.setValue(128) # nice default
145
+ self.s_tile.setToolTip("CLAHE tile size in pixels (larger = coarser, smaller = finer).")
146
+
147
+ self.lbl_tile = QLabel("128 px")
148
+ self.lbl_tile.setToolTip(self.s_tile.toolTip())
149
+
150
+ self.s_tile.valueChanged.connect(lambda v: self.lbl_tile.setText(f"{v} px"))
151
+ self.s_tile.valueChanged.connect(self._debounce_preview)
152
+
153
+ grid.addWidget(QLabel("Tile Size (px):"), 1, 0)
154
+ grid.addWidget(self.s_tile, 1, 1)
155
+ grid.addWidget(self.lbl_tile, 1, 2)
156
+
157
+
158
+
159
+ grid.addWidget(QLabel("Clip Limit:"), 0, 0); grid.addWidget(self.s_clip, 0, 1); grid.addWidget(self.lbl_clip, 0, 2)
160
+
161
+ v.addWidget(grp)
162
+
163
+ # ---- Preview with zoom/pan ----
164
+ self.scene = QGraphicsScene(self)
165
+ self.view = ZoomableGraphicsView(self.scene)
166
+ self.view.setAlignment(Qt.AlignmentFlag.AlignCenter)
167
+ self.pix = QGraphicsPixmapItem()
168
+ self.scene.addItem(self.pix)
169
+ v.addWidget(self.view, 1)
170
+
171
+ # ---- Zoom bar ----
172
+ # ---- Zoom bar (themed) ----
173
+ z = QHBoxLayout()
174
+ z.addStretch(1)
175
+
176
+ self.btn_zoom_in = themed_toolbtn("zoom-in", "Zoom In")
177
+ self.btn_zoom_out = themed_toolbtn("zoom-out", "Zoom Out")
178
+ self.btn_zoom_fit = themed_toolbtn("zoom-fit-best", "Fit to Preview")
179
+
180
+ self.btn_zoom_in.clicked.connect(self.view.zoom_in)
181
+ self.btn_zoom_out.clicked.connect(self.view.zoom_out)
182
+ self.btn_zoom_fit.clicked.connect(lambda: self.view.fit_to_item(self.pix))
183
+
184
+ z.addWidget(self.btn_zoom_in)
185
+ z.addWidget(self.btn_zoom_out)
186
+ z.addWidget(self.btn_zoom_fit)
187
+
188
+ v.addLayout(z)
189
+
190
+
191
+ # ---- Buttons (unchanged) ----
192
+ row = QHBoxLayout()
193
+ self.btn_apply = QPushButton("Apply"); self.btn_apply.clicked.connect(self._apply)
194
+ self.btn_reset = QPushButton("Reset"); self.btn_reset.clicked.connect(self._reset)
195
+ self.btn_close = QPushButton("Cancel"); self.btn_close.clicked.connect(self.reject)
196
+ row.addStretch(1); row.addWidget(self.btn_apply); row.addWidget(self.btn_reset); row.addWidget(self.btn_close)
197
+ v.addLayout(row)
198
+
199
+ self._timer = QTimer(self); self._timer.setSingleShot(True); self._timer.timeout.connect(self._update_preview)
200
+
201
+ self._set_pix(self._disp_base)
202
+ self._update_preview()
203
+ # initial fit
204
+ self.view.fit_to_item(self.pix)
205
+
206
+ def _debounce_preview(self): self._timer.start(250)
207
+
208
+ def _set_pix(self, rgb):
209
+ arr = (np.clip(rgb, 0, 1) * 255).astype(np.uint8)
210
+ h, w, _ = arr.shape
211
+ q = QImage(arr.data, w, h, 3*w, QImage.Format.Format_RGB888)
212
+ self.pix.setPixmap(QPixmap.fromImage(q))
213
+ self.scene.setSceneRect(self.pix.boundingRect())
214
+
215
+ def _update_preview(self):
216
+ clip = self.s_clip.value() / 10.0
217
+ tile_px = int(self.s_tile.value())
218
+
219
+ try:
220
+ tile_grid = self._tile_grid_from_px(tile_px, self._disp_base.shape[:2])
221
+
222
+ out = apply_clahe(
223
+ self._disp_base,
224
+ clip_limit=float(clip),
225
+ tile_grid_size=tile_grid
226
+ )
227
+
228
+ # Respect active mask (preview works on _disp_base size)
229
+ H, W = out.shape[:2]
230
+ m = _get_active_mask_resized(self.doc, H, W)
231
+ if m is not None:
232
+ if out.ndim == 3:
233
+ M = np.repeat(m[:, :, None], out.shape[2], axis=2).astype(np.float32)
234
+ else:
235
+ M = m.astype(np.float32)
236
+
237
+ base = self._disp_base.astype(np.float32, copy=False)
238
+ out = np.clip(base * (1.0 - M) + out * M, 0.0, 1.0)
239
+
240
+ self._set_pix(out)
241
+ self._preview = out
242
+
243
+ except Exception as e:
244
+ QMessageBox.warning(self, "CLAHE", f"Preview failed:\n{e}")
245
+
246
+
247
+ def _apply(self):
248
+ try:
249
+ clip = float(self.s_clip.value() / 10.0)
250
+ tile_px = int(self.s_tile.value())
251
+
252
+ tile_grid = self._tile_grid_from_px(tile_px, self.orig.shape[:2])
253
+
254
+ out = apply_clahe(
255
+ self.orig,
256
+ clip_limit=clip,
257
+ tile_grid_size=tile_grid
258
+ )
259
+ out = np.clip(out, 0.0, 1.0).astype(np.float32, copy=False)
260
+
261
+ # Mask-respectful commit
262
+ H, W = out.shape[:2]
263
+ m = _get_active_mask_resized(self.doc, H, W)
264
+ if m is not None:
265
+ base = np.asarray(self.doc.image, dtype=np.float32)
266
+
267
+ # Normalize base into [0..1] for blending
268
+ if base.dtype.kind in "ui":
269
+ maxv = float(np.iinfo(base.dtype).max)
270
+ base = base / max(1.0, maxv)
271
+ else:
272
+ base = np.clip(base, 0.0, 1.0)
273
+
274
+ if out.ndim == 3:
275
+ if base.ndim == 2:
276
+ base = base[:, :, None].repeat(out.shape[2], axis=2)
277
+ elif base.ndim == 3 and base.shape[2] == 1:
278
+ base = base.repeat(out.shape[2], axis=2)
279
+
280
+ M = np.repeat(m[:, :, None], out.shape[2], axis=2).astype(np.float32)
281
+ out = np.clip(base * (1.0 - M) + out * M, 0.0, 1.0)
282
+ else:
283
+ if base.ndim == 3 and base.shape[2] == 1:
284
+ base = base.squeeze(axis=2)
285
+ out = np.clip(base * (1.0 - m) + out * m, 0.0, 1.0)
286
+
287
+ out = out.astype(np.float32, copy=False)
288
+
289
+ # Commit to document
290
+ if hasattr(self.doc, "set_image"):
291
+ self.doc.set_image(out, step_name="CLAHE")
292
+ elif hasattr(self.doc, "apply_numpy"):
293
+ self.doc.apply_numpy(out, step_name="CLAHE")
294
+ else:
295
+ self.doc.image = out
296
+
297
+ # ── Register as last_headless_command for replay ─────────────
298
+ try:
299
+ main = self.parent()
300
+ if main is not None:
301
+ preset = {
302
+ "clip_limit": float(clip),
303
+ "tile_px": int(tile_px), # NEW, intuitive
304
+ # (optional debug)
305
+ # "tile": int(tile_grid[0]),
306
+ }
307
+ payload = {"command_id": "clahe", "preset": dict(preset)}
308
+ setattr(main, "_last_headless_command", payload)
309
+
310
+ try:
311
+ if hasattr(main, "_log"):
312
+ main._log(
313
+ f"[Replay] Registered CLAHE as last action "
314
+ f"(clip_limit={preset['clip_limit']}, tile_px={preset['tile_px']})"
315
+ )
316
+ except Exception:
317
+ pass
318
+ except Exception:
319
+ pass
320
+ # ─────────────────────────────────────────────────────────────
321
+
322
+ self.accept()
323
+
324
+ except Exception as e:
325
+ QMessageBox.critical(self, "CLAHE", f"Failed to apply:\n{e}")
326
+
327
+ def _tile_grid_from_px(self, tile_px: int, hw: tuple[int, int]) -> tuple[int, int]:
328
+ """
329
+ Convert desired tile size (pixels) into OpenCV tileGridSize=(n,n)
330
+ where n is number of tiles across the *min dimension*.
331
+ """
332
+ h, w = hw
333
+ s = float(min(h, w))
334
+ tile_px = max(8, int(tile_px))
335
+ n = int(round(s / float(tile_px)))
336
+ n = max(2, min(n, 128))
337
+ return (n, n)
338
+
339
+ def _reset(self):
340
+ self.s_clip.setValue(20); self.s_tile.setValue(8)
341
+ self._set_pix(self._disp_base)
342
+ self.view.fit_to_item(self.pix)